LANCommander/LANCommander.Packager/Services/InstallerMonitorService.cs
Pat Hartl 819cb28d17 Packager app improvements, metadata endpoints
- Add ability to connect to server
- Add uploading of finished package to server
- Add metadata lookup when connected to server
- Fix layout when specifying action
- Hide finish button at generate step
2026-06-03 19:43:45 -05:00

416 lines
13 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using LANCommander.Interposer;
using LANCommander.Packager.Models;
namespace LANCommander.Packager.Services;
public class InstallerMonitorService : IDisposable
{
private readonly List<InterposerService> _interposers = new();
private readonly ConcurrentDictionary<int, bool> _injectedPids = new();
private readonly ConcurrentDictionary<string, FileChangeEntry> _fileChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentBag<RegistryChangeEntry> _registryChanges = new();
private CancellationTokenSource? _childMonitorCts;
private static readonly string[] IgnoredPathPrefixes = new[]
{
Environment.GetFolderPath(Environment.SpecialFolder.Windows),
Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar),
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\Temp",
};
private static readonly string[] WriteVerbs =
{
"FILE WRITE",
"FILE R/W",
"FILE COPY",
"FILE MOVE"
};
private static readonly string[] RegistryWriteVerbs =
{
"REG WRITE",
"REG CREATE"
};
public IReadOnlyCollection<FileChangeEntry> FileChanges => _fileChanges.Values.ToList();
public IReadOnlyCollection<RegistryChangeEntry> RegistryChanges => _registryChanges.ToList();
public event Action<FileChangeEntry>? OnFileChange;
public event Action<RegistryChangeEntry>? OnRegistryChange;
public event Action? OnInstallerExited;
public int FileChangeCount => _fileChanges.Count;
public int RegistryChangeCount => _registryChanges.Count;
private Action<string>? _log;
public string LaunchInstaller(string installerPath, Action<string>? log = null)
{
_log = log;
installerPath = Path.GetFullPath(installerPath);
if (!File.Exists(installerPath))
throw new FileNotFoundException($"Installer not found: {installerPath}");
var dllPath = ResolveNativeDllForTarget(installerPath);
log?.Invoke($"Native DLL: {dllPath}");
var interposer = CreateInterposer(dllPath, log);
var psi = new ProcessStartInfo(installerPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(installerPath)
};
var process = interposer.Start(psi, dllPath);
_injectedPids.TryAdd(process.Id, true);
log?.Invoke($"Interposer injected (PID {process.Id}).");
process.EnableRaisingEvents = true;
process.Exited += (_, _) =>
{
// Give child processes a moment to finish
Thread.Sleep(1000);
_childMonitorCts?.Cancel();
OnInstallerExited?.Invoke();
};
// Start monitoring for child processes
_childMonitorCts = new CancellationTokenSource();
Task.Run(() => MonitorChildProcesses(process.Id, dllPath, _childMonitorCts.Token));
return "live";
}
private InterposerService CreateInterposer(string? dllPath, Action<string>? log)
{
var interposer = new InterposerService();
_interposers.Add(interposer);
interposer.PipeDiagnostic += (sender, msg) => log?.Invoke(msg);
interposer.FileAccessed += (sender, e) =>
{
if (string.IsNullOrEmpty(e.Path))
return;
log?.Invoke($"[FILE] {e.Verb}: {e.Path}");
if (!IsWriteVerb(e.Verb) || IsIgnoredPath(e.Path))
return;
var entry = new FileChangeEntry { Verb = e.Verb, Path = e.Path };
_fileChanges.AddOrUpdate(e.Path, entry, (_, _) => entry);
OnFileChange?.Invoke(entry);
};
interposer.RegistryAccessed += (sender, e) =>
{
if (string.IsNullOrEmpty(e.KeyPath))
return;
log?.Invoke($"[REG] {e.Verb}: {e.KeyPath}");
if (!IsRegistryWriteVerb(e.Verb))
return;
var entry = new RegistryChangeEntry
{
Verb = e.Verb,
KeyPath = e.KeyPath,
ValueName = e.ValueName ?? string.Empty
};
_registryChanges.Add(entry);
OnRegistryChange?.Invoke(entry);
};
return interposer;
}
private async Task MonitorChildProcesses(int parentPid, string? dllPath, CancellationToken ct)
{
_log?.Invoke("Monitoring for child processes...");
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(500, ct);
var children = GetChildProcessIds(parentPid);
foreach (var childPid in children)
{
if (_injectedPids.TryAdd(childPid, true))
{
try
{
_log?.Invoke($"Injecting into child process PID {childPid}...");
var childInterposer = CreateInterposer(dllPath, _log);
childInterposer.Inject(childPid, dllPath);
_log?.Invoke($"Injected into child PID {childPid}.");
// Also monitor grandchildren
_ = Task.Run(() => MonitorChildProcesses(childPid, dllPath, ct));
}
catch (Exception ex)
{
_log?.Invoke($"Failed to inject into PID {childPid}: {ex.Message}");
}
}
}
}
catch (TaskCanceledException) { break; }
catch (Exception ex)
{
_log?.Invoke($"Child monitor error: {ex.Message}");
}
}
}
#region Child process detection via toolhelp32
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
[DllImport("kernel32.dll")]
private static extern bool Process32First(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
[DllImport("kernel32.dll")]
private static extern bool Process32Next(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr hObject);
private const uint TH32CS_SNAPPROCESS = 0x00000002;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private 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;
}
private static List<int> GetChildProcessIds(int parentPid)
{
var children = new List<int>();
var snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1))
return children;
try
{
var entry = new PROCESSENTRY32 { dwSize = (uint)Marshal.SizeOf<PROCESSENTRY32>() };
if (Process32First(snapshot, ref entry))
{
do
{
if (entry.th32ParentProcessID == (uint)parentPid)
children.Add((int)entry.th32ProcessID);
}
while (Process32Next(snapshot, ref entry));
}
}
finally
{
CloseHandle(snapshot);
}
return children;
}
#endregion
#region Install directory detection
public string DetectInstallDirectory()
{
var filePaths = _fileChanges.Keys
.Where(p => !string.IsNullOrWhiteSpace(p) && Path.IsPathRooted(p))
.Select(p => Path.GetDirectoryName(p))
.Where(p => p != null)
.Cast<string>()
.ToList();
if (filePaths.Count == 0)
return string.Empty;
// Filter to non-system, non-ignored paths first
var nonSystemPaths = filePaths.Where(p => !IsIgnoredPath(p) && !IsSystemPath(p)).ToList();
if (nonSystemPaths.Count > 0)
{
// Find the common ancestor of all game file paths. This handles
// installers that write many files into deep subdirectories
// (e.g. Sounds/, _CD/SETUP/) — picking the deepest single
// directory with the most files would miss the actual root.
var ancestor = FindCommonAncestor(nonSystemPaths);
// If the ancestor is meaningful (not just a drive root like "G:\"),
// use it. Otherwise fall back to the most-frequent directory.
if (!string.IsNullOrEmpty(ancestor) && !IsDriveRoot(ancestor))
return ancestor;
// Fall back: pick the most frequent non-system directory
var directoryGroups = nonSystemPaths
.GroupBy(p => p, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ToList();
if (directoryGroups.Count > 0)
return directoryGroups[0].Key;
}
return filePaths.First();
}
private static bool IsDriveRoot(string path)
{
var root = Path.GetPathRoot(path);
return string.Equals(path.TrimEnd(Path.DirectorySeparatorChar),
root?.TrimEnd(Path.DirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase);
}
private static string FindCommonAncestor(List<string> paths)
{
if (paths.Count == 0)
return string.Empty;
if (paths.Count == 1)
return paths[0];
var splits = paths.Select(p => p.Split(Path.DirectorySeparatorChar)).ToList();
var minLength = splits.Min(s => s.Length);
var common = new List<string>();
for (int i = 0; i < minLength; i++)
{
var segment = splits[0][i];
if (splits.All(s => s[i].Equals(segment, StringComparison.OrdinalIgnoreCase)))
common.Add(segment);
else
break;
}
return string.Join(Path.DirectorySeparatorChar, common);
}
#endregion
#region Architecture detection
private static string? ResolveNativeDllForTarget(string exePath)
{
bool targetIs64 = IsPE64Bit(exePath);
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var candidates = targetIs64
? new[]
{
Path.Combine(baseDir, "runtimes", "win-x64", "native", "LANCommander.Interposer.dll"),
Path.Combine(baseDir, "LANCommander.Interposer.dll"),
}
: new[]
{
Path.Combine(baseDir, "runtimes", "win-x86", "native", "LANCommander.Interposer.dll"),
};
foreach (var candidate in candidates)
{
var resolved = Path.GetFullPath(candidate);
if (File.Exists(resolved))
return resolved;
}
return null;
}
private static bool IsPE64Bit(string exePath)
{
try
{
using var fs = File.OpenRead(exePath);
using var reader = new BinaryReader(fs);
fs.Seek(0x3C, SeekOrigin.Begin);
int peOffset = reader.ReadInt32();
fs.Seek(peOffset, SeekOrigin.Begin);
uint peSignature = reader.ReadUInt32();
if (peSignature != 0x00004550)
return Environment.Is64BitProcess;
ushort machine = reader.ReadUInt16();
return machine == 0x8664 || machine == 0xAA64;
}
catch
{
return Environment.Is64BitProcess;
}
}
#endregion
#region Verb/path filters
private static bool IsWriteVerb(string verb)
=> WriteVerbs.Any(v => verb.Equals(v, StringComparison.OrdinalIgnoreCase));
private static bool IsRegistryWriteVerb(string verb)
=> RegistryWriteVerbs.Any(v => verb.Equals(v, StringComparison.OrdinalIgnoreCase));
private static bool IsIgnoredPath(string path)
=> IgnoredPathPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
private static bool IsSystemPath(string path)
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
if (path.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase) ||
path.StartsWith(programFilesX86, StringComparison.OrdinalIgnoreCase))
return false;
var systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
return path.StartsWith(systemRoot, StringComparison.OrdinalIgnoreCase);
}
#endregion
public void Dispose()
{
_childMonitorCts?.Cancel();
_childMonitorCts?.Dispose();
foreach (var interposer in _interposers)
interposer.Dispose();
_interposers.Clear();
}
}