GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
Launcher.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.Diagnostics;
20using System.Linq;
21using System.Management;
22using System.Text;
23using System.Threading.Tasks;
24using static GenerallyPositive.Enums;
25
26namespace GenerallyPositive
27{
28 // written by Grok
30 {
31 public string Name { get; internal set; }
32 public string Executable { get; internal set; }
33
34 private readonly List<string> _arguments = new List<string>();
35 private readonly Dictionary<string, string> _environment = new Dictionary<string, string>();
36 private string _workingDirectory;
37 private Action<string> _outputHandler;
38 private Action<string> _errorHandler;
39 private Process _process;
40 private bool _hasWaitedOrExited;
41
42 public Launcher()
43 {
44 }
45
46 public IAllowExecutableSetup WithExecutable(string executable)
47 {
48 Executable = executable;
49 return this;
50 }
51
52 public IAllowExecutableSetup WithName(string name)
53 {
54 Name = name;
55 return this;
56 }
57 public IAllowExecutableSetup WithArgument(string argument)
58 {
59 if (_process != null)
60 {
61 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Cannot add arguments after starting.", null, GPALObjectType.None);
62 return this; // Return to allow chaining, but operation is skipped
63 }
64 if (argument == null)
65 {
66 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Argument cannot be null.", null, GPALObjectType.None, new ArgumentNullException(nameof(argument)));
67 return this;
68 }
69 _arguments.Add(argument);
70 return this;
71 }
72
81 {
82 if (null == directory)
83 {
84 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "A working directory cannot be null", null, GPALObjectType.None);
85 return this;
86 }
87
88 _workingDirectory = directory;
89 return this;
90 }
91
100 public IAllowExecutableSetup WithEnvironmentVariable(string name, string value)
101 {
102 if (null == name || null == value)
103 {
104 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"An environment variable needs a name and a value, given [{name ?? "NULL"}] and [{value ?? "NULL"}]", null, GPALObjectType.None);
105 return this;
106 }
107
108 _environment[name] = value;
109 return this;
110 }
111
119 public IAllowExecutableSetup WithOutputTo(Action<string> outputHandler)
120 {
121 _outputHandler = outputHandler;
122 return this;
123 }
124
131 public IAllowExecutableSetup WithErrorTo(Action<string> errorHandler)
132 {
133 _errorHandler = errorHandler;
134 return this;
135 }
136
143 private static string Quoted(string argument)
144 {
145 if (false == argument.Contains(" ") && false == argument.Contains("\""))
146 return argument;
147
148 return "\"" + argument.Replace("\"", "\\\"") + "\"";
149 }
150
151 // what Windows starts directly, without asking the shell what the file is registered to. A path with
152 // no extension is one too, because Windows looks for it with .exe added
153 private static readonly HashSet<string> runsDirectly = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
154 {
155 ".exe", ".com", ".bat", ".cmd"
156 };
157
165 private static bool IsDocument(string path)
166 {
167 string name = path.Trim().Trim('"');
168 name = name.Substring(Math.Max(name.LastIndexOf('\\'), name.LastIndexOf('/')) + 1);
169
170 int dot = name.LastIndexOf('.');
171
172 return 0 <= dot && dot < name.Length - 1 && false == runsDirectly.Contains(name.Substring(dot));
173 }
174
175 public IAllowRunningExecutable Launch()
176 {
177 if (_process != null)
178 {
179 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Process already started.", null, GPALObjectType.None);
180 return this;
181 }
182
183 // a url or a document is opened by whatever Windows has registered for it, which is what
184 // the shell does. an executable GPAL runs itself
185 bool opensWithTheShell = Executable.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
186 || Executable.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
187 || true == IsDocument(Executable);
188
189 // reading the output and setting the environment both need the process started by GPAL rather
190 // than handed to the shell, so asking for either takes the shell out of it
191 bool wantsOurOwnProcess = null != _outputHandler || null != _errorHandler || 0 < _environment.Count;
192
193 if (true == opensWithTheShell && true == wantsOurOwnProcess)
194 {
195 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{Executable}] opens with the shell, which has no output to read and no environment to set. Those settings are ignored", null, GPALObjectType.None);
196 wantsOurOwnProcess = false;
197 _outputHandler = null;
198 _errorHandler = null;
199
200 // the shell refuses to start anything that carries an environment, so it goes too
201 _environment.Clear();
202 }
203
204 bool shellExecute = opensWithTheShell && false == wantsOurOwnProcess;
205
206 _process = new Process
207 {
208 StartInfo = new ProcessStartInfo
209 {
210 FileName = Executable,
211 Arguments = string.Join(" ", _arguments.Select(Quoted)),
212 UseShellExecute = shellExecute,
213 RedirectStandardOutput = null != _outputHandler,
214 RedirectStandardError = null != _errorHandler,
215 CreateNoWindow = !shellExecute
216 }
217 };
218
219 if (false == string.IsNullOrEmpty(_workingDirectory))
220 _process.StartInfo.WorkingDirectory = _workingDirectory;
221
222 foreach (KeyValuePair<string, string> variable in _environment)
223 _process.StartInfo.EnvironmentVariables[variable.Key] = variable.Value;
224
225 // attached before Start, because a line written before the handler is on is a line nobody sees
226 if (null != _outputHandler)
227 _process.OutputDataReceived += (sender, e) => { if (null != e.Data) _outputHandler(e.Data); };
228
229 if (null != _errorHandler)
230 _process.ErrorDataReceived += (sender, e) => { if (null != e.Data) _errorHandler(e.Data); };
231
232 try
233 {
234 _process.Start();
235
236 // reading only starts once the process has, and only for the streams that were redirected
237 if (null != _outputHandler)
238 _process.BeginOutputReadLine();
239
240 if (null != _errorHandler)
241 _process.BeginErrorReadLine();
242 }
243 catch (Exception ex)
244 {
245 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to start [{Executable}]", null, GPALObjectType.None, ex);
246 }
247
248 return this;
249 }
250
256 public bool IsRunning()
257 {
258 bool retVal = null != _process && false == _hasWaitedOrExited && false == _process.HasExited;
259
260 return retVal;
261 }
262
268 public IAllowWaitingExecutable WaitForExit(int milliseconds)
269 {
270 if (true == Started("WaitForExit"))
271 {
272 _process.WaitForExit(milliseconds);
273 _hasWaitedOrExited = true;
274 }
275
276 return this;
277 }
278
282 public void Kill()
283 {
284 if (false == Started("Kill") || false == IsRunning())
285 return;
286
287 _process.Kill();
288 _hasWaitedOrExited = true;
289 }
290
294 public void KillTree()
295 {
296 if (false == Started("KillTree") || false == IsRunning())
297 return;
298
299 KillProcessTree(_process.Id);
300 _process.Kill();
301 _hasWaitedOrExited = true;
302 }
303 private void KillProcessTree(int pid)
304 {
305 try
306 {
307 // Use WMI to find child processes
308 using (var searcher = new ManagementObjectSearcher(
309 $"SELECT * FROM Win32_Process WHERE ParentProcessId = {pid}"))
310 {
311 foreach (ManagementObject obj in searcher.Get())
312 {
313 int childPid = Convert.ToInt32(obj["ProcessId"]);
314 KillProcessTree(childPid); // Recursively kill children
315 try
316 {
317 Process.GetProcessById(childPid).Kill();
318 }
319 catch (ArgumentException)
320 {
321 // Process already exited, ignore
322 }
323 }
324 }
325 }
326 catch (Exception ex)
327 {
328 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to kill process tree for PID [{pid}]", null, GPALObjectType.None, ex);
329 }
330 }
331 public Launcher ToGPALObject()
332 {
333 return this;
334 }
335
343 private bool Started(string wanted)
344 {
345 bool retVal = null != _process;
346
347 if (false == retVal)
348 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{wanted}] needs a process, and [{Executable ?? "no executable"}] has not been launched", null, GPALObjectType.None);
349
350 return retVal;
351 }
352 }
353}
354
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2568
void Kill()
Stop the process. What it started keeps running; KillTree is for those.
Definition Launcher.cs:282
IAllowWaitingExecutable WaitForExit(int milliseconds)
Wait up to this many milliseconds for the process to end.
Definition Launcher.cs:268
IAllowExecutableSetup WithOutputTo(Action< string > outputHandler)
Where each line the process writes to standard output goes. Said before Launch: reading a process's ...
Definition Launcher.cs:119
IAllowExecutableSetup WithEnvironmentVariable(string name, string value)
One environment variable the process starts with. Called once per variable. The process inherits thi...
Definition Launcher.cs:100
void KillTree()
Stop the process and everything it started, children first so nothing is orphaned.
Definition Launcher.cs:294
IAllowExecutableSetup WithWorkingDirectory(string directory)
The directory the process starts in. Said before Launch, because the working directory is part of ho...
Definition Launcher.cs:80
bool IsRunning()
Whether the process is still running. False before Launch, and false once it has ended,...
Definition Launcher.cs:256
IAllowExecutableSetup WithErrorTo(Action< string > errorHandler)
Where each line the process writes to standard error goes. Said before Launch, for the same reason as...
Definition Launcher.cs:131