Skip to content

Instantly share code, notes, and snippets.

@jborean93
Last active April 12, 2025 19:01
Show Gist options
  • Save jborean93/d8e84c4ab744d0237a1ff650acec1086 to your computer and use it in GitHub Desktop.
Save jborean93/d8e84c4ab744d0237a1ff650acec1086 to your computer and use it in GitHub Desktop.
Generates an exe called NoGui.exe that can spawn a hidden windows
<#
NOTE: Must be run in Windows PowerShell (5.1), PowerShell (7+) cannot create standalone exes.
This is designed to create a simple exe that can be used to spawn any console
application with a hidden Window. As NoGui.exe is a GUI executable it won't
spawn with an associated console window and can be used to then create a new
process with a hidden console window with the arguments it was created with.
By default, NoGui will spawn the child process with same stdio handles as
itself. This allows the caller to interact with the stdio of the child process
if they spawn NoGui with the proper handles. Please note that most tools which
spawn a GUI application will not setup the stdio handles, in those cases the
'-stdin', '-stdout', and '-stderr' arguments can be used instead.
NoGui will also spawn the process and wait for it to end before it ends itself.
This allows it to set the exit code of its process to the one it spawned. Use
the '-nowait' argument to exit as soon as the process has been created.
The command line arguments for NoGui comes in two parts:
NoGui.exe [-nowait] [-logpath PATH] [-stdin PATH] [-stdout PATH]
[-stderr PATH] -- child process command line
The options before '--' are options to control the behaviour of NoGui.exe:
-nowait
Will spawn the process and not wait for it to finish before exiting. By
default NoGui will spawn the process and wait for it to end to pass
along the return code from the child process. If set the return code
will be 0 if the process was successfully created.
-log PATH
The path to log the operations of NoGui for debugging purposes. The
directory specified in the path must exist.
-stdin PATH
The path to a file to set as the stdin of the process to spawn.
-stdout PATH
The path to a file to set as the stdout of the process to spawn.
-stdin PATH
The path to a file to set as the stderr of the process to spawn.
The arguments after -- will be used as the new process. This must be set
otherwise NoGui won't know what process to create.
Examples:
Spawn pwsh.exe and wait for it to exit
NoGui.exe -- pwsh.exe
Run pwsh.exe from an absolute path that needs to be quoted. The exit code
for NoGui.exe will based on the exit code from pwsh.exe.
NoGui.exe -- "C:\Program Files\PowerShell\7\pwsh.exe" -Command "'abc' > C:\Windows\TEMP\test.txt; exit 2"
Run pwsh.exe but will not wait for it to complete.
NoGui.exe -nowait -- pwsh.exe -Command "'abc'"
Runs NoGui with a path to log its operations for debugging
NoGui.exe -log C:\temp\NoGui.log -- pwsh.exe -Command "'abc'"
Runs NoGui with a custom stdout and stderr path from the current directory
NoGui.exe -stdout stdout.txt -stderr stderr.exe -- pwsh.exe -Command "$host.UI.WriteLine('stdout'); $host.UI.WriteErrorLine('stderr')"
Will create a new process `pwsh.exe` but will not wait for it to finish before exiting.
#>
Add-Type -OutputType WindowsApplication -OutputAssembly NoGui.exe -TypeDefinition @'
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace NoGui
{
class Program
{
public const int INFINITE = -1;
public const int STARTF_USESHOWWINDOW = 0x00000001;
public const int STARTF_USESTDHANDLES = 0x00000100;
public const int SW_HIDE = 0;
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[StructLayout(LayoutKind.Sequential)]
public struct STARTUPINFOW
{
public Int32 cb;
public IntPtr lpReserved;
public IntPtr lpDesktop;
public IntPtr lpTitle;
public Int32 dwX;
public Int32 dwY;
public Int32 dwXSize;
public Int32 dwYSize;
public Int32 dwXCountChars;
public Int32 dwYCountChars;
public Int32 dwFillAttribute;
public Int32 dwFlags;
public Int16 wShowWindow;
public Int16 cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[DllImport("Kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(
IntPtr hObject);
[DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessW(
[MarshalAs(UnmanagedType.LPWStr)] string lpApplicationName,
StringBuilder lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
int dwCreationFlags,
IntPtr lpEnvironment,
[MarshalAs(UnmanagedType.LPWStr)] string lpCurrentDirectory,
ref STARTUPINFOW lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("Kernel32.dll", SetLastError = true)]
public static extern bool GetExitCodeProcess(
IntPtr hProcess,
out int lpExitCode);
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode)]
public static extern void GetStartupInfoW(
out STARTUPINFOW lpStartupInfo);
[DllImport("Kernel32.dll", SetLastError = true)]
public static extern int WaitForSingleObject(
IntPtr hHandle,
int dwMilliseconds);
static int Main(string[] args)
{
CommandLineArgs parsedArgs = CommandLineArgs.Parse(args);
NoGuiLogger logger = new NoGuiLogger(parsedArgs.LogPath);
FileStream stdinStream = null;
FileStream stdoutStream = null;
FileStream stderrStream = null;
try
{
logger.Log("Starting with options {0}", parsedArgs);
STARTUPINFOW startupInfo;
GetStartupInfoW(out startupInfo);
IntPtr stdinHandle = startupInfo.hStdInput;
IntPtr stdoutHandle = startupInfo.hStdOutput;
IntPtr stderrHandle = startupInfo.hStdError;
if (string.IsNullOrEmpty(parsedArgs.ProcessCommandLine))
{
logger.Log("No command line was found");
return -1;
}
if (!string.IsNullOrEmpty(parsedArgs.StdinPath))
{
if (!File.Exists(parsedArgs.StdinPath))
{
logger.Log("Path for stdin '{0}' was not found", parsedArgs.StdinPath);
return -1;
}
stdinStream = File.Open(parsedArgs.StdinPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Inheritable);
stdinHandle = stdinStream.SafeFileHandle.DangerousGetHandle();
}
if (!string.IsNullOrEmpty(parsedArgs.StdoutPath))
{
if (!Directory.Exists(Path.GetDirectoryName(parsedArgs.StdoutPath)))
{
logger.Log("Parent directory for stdout '{0}' was not found", parsedArgs.StdoutPath);
return -1;
}
stdoutStream = File.Open(parsedArgs.StdoutPath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Inheritable);
stdoutHandle = stdoutStream.SafeFileHandle.DangerousGetHandle();
}
if (!string.IsNullOrEmpty(parsedArgs.StderrPath))
{
if (!Directory.Exists(Path.GetDirectoryName(parsedArgs.StderrPath)))
{
logger.Log("Parent directory for stderr '{0}' was not found", parsedArgs.StderrPath);
return -1;
}
if (parsedArgs.StderrPath == parsedArgs.StdoutPath)
{
stderrHandle = stdoutHandle;
}
else
{
stderrStream = File.Open(parsedArgs.StderrPath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Inheritable);
stderrHandle = stderrStream.SafeFileHandle.DangerousGetHandle();
}
}
return StartProcess(
parsedArgs.ProcessCommandLine,
parsedArgs.NoWait,
stdinHandle,
stdoutHandle,
stderrHandle,
logger);
}
catch (Exception e)
{
logger.Log("Uncaught exception {0}", e.ToString());
return -1;
}
finally
{
if (stdinStream != null) stdinStream.Dispose();
if (stdoutStream != null) stdoutStream.Dispose();
if (stderrStream != null) stderrStream.Dispose();
logger.Dispose();
}
}
private static int StartProcess(
string commandLine,
bool noWait,
IntPtr stdin,
IntPtr stdout,
IntPtr stderr,
NoGuiLogger logger)
{
STARTUPINFOW si = new STARTUPINFOW()
{
cb = Marshal.SizeOf<STARTUPINFOW>(),
dwFlags = STARTF_USESHOWWINDOW,
wShowWindow = SW_HIDE,
hStdInput = stdin,
hStdOutput = stdout,
hStdError = stderr,
};
if (stdin != IntPtr.Zero || stdout != IntPtr.Zero || stderr != IntPtr.Zero)
{
logger.Log(
"Adding STARTF_USESTDHANDLES flag - stdin 0x{0:X8} - stdout 0x{1:X8} - stderr 0x{2:X8}",
stdin.ToInt64(),
stdout.ToInt64(),
stderr.ToInt64());
si.dwFlags |= STARTF_USESTDHANDLES;
}
PROCESS_INFORMATION pi;
StringBuilder commandLineBuffer = new StringBuilder(commandLine);
logger.Log("Starting new process with command line - {0}", commandLine);
bool res = CreateProcessW(
null,
commandLineBuffer,
IntPtr.Zero,
IntPtr.Zero,
true,
0x00000410, // CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT
IntPtr.Zero,
null,
ref si,
out pi
);
if (res)
{
logger.Log("Child process successfully starts with PID {0}", pi.dwProcessId);
try
{
if (noWait)
{
logger.Log("-nowait has been set, exiting early");
return 0;
}
logger.Log("Waiting for process to end");
int waitRes = WaitForSingleObject(pi.hProcess, INFINITE);
if (waitRes == 0)
{
logger.Log("Process ended, getting exit code");
int exitCode;
if (GetExitCodeProcess(pi.hProcess, out exitCode))
{
logger.Log("Process exit code {0}", exitCode);
return exitCode;
}
else
{
Win32Exception exp = new Win32Exception();
logger.Log("GetExitCodeProcess failed 0x{0:X8} - {1}", exp.NativeErrorCode, exp.Message);
}
}
else
{
Win32Exception exp = new Win32Exception();
logger.Log("WaitForSingleObject failed 0x{0:X8} - {1}", exp.NativeErrorCode, exp.Message);
}
}
finally
{
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
}
}
else
{
Win32Exception exp = new Win32Exception();
logger.Log("CreateProcess failed 0x{0:X8} - {1}", exp.NativeErrorCode, exp.Message);
}
return -1;
}
}
}
internal class CommandLineArgs
{
[DllImport("Kernel32.dll")]
private static extern IntPtr GetCommandLineW();
private CommandLineArgs(
string processCommandLine,
bool noWait,
string stdinPath,
string stdoutPath,
string stderrPath,
string logPath)
{
ProcessCommandLine = processCommandLine;
NoWait = noWait;
StdinPath = stdinPath;
StdoutPath = stdoutPath;
StderrPath = stderrPath;
LogPath = logPath;
}
public string ProcessCommandLine { get; private set; }
public bool NoWait { get; private set; }
public string StdinPath { get; private set; }
public string StdoutPath { get; private set; }
public string StderrPath { get; private set; }
public string LogPath { get; private set; }
public static CommandLineArgs Parse(string[] args)
{
string processCommandLine = null;
bool noWait = false;
string stdinPath = null;
string stdoutPath = null;
string stderrPath = null;
string logPath = null;
// Do not free this, it's a pointer to an address in the PEB.
// We get the raw command line rather than use the args so we
// don't need to worry about escaping.
IntPtr cmdLinePtr = GetCommandLineW();
string cmdLine = Marshal.PtrToStringUni(cmdLinePtr);
int cmdLineArgsIdx = cmdLine.IndexOf(" -- ");
if (cmdLineArgsIdx != -1)
{
processCommandLine = cmdLine.Substring(cmdLineArgsIdx + 4);
}
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
string nextArg = null;
if ((i + 1) < args.Length)
{
nextArg = args[i + 1];
}
if (arg == "--")
{
break;
}
else if (arg.Equals("-nowait", StringComparison.OrdinalIgnoreCase))
{
noWait = true;
}
else if (nextArg != null && nextArg != "--")
{
if (arg.Equals("-stdin", StringComparison.OrdinalIgnoreCase))
{
stdinPath = Path.GetFullPath(nextArg);
i++;
}
else if (arg.Equals("-stdout", StringComparison.OrdinalIgnoreCase))
{
stdoutPath = Path.GetFullPath(nextArg);
i++;
}
else if (arg.Equals("-stderr", StringComparison.OrdinalIgnoreCase))
{
stderrPath = Path.GetFullPath(nextArg);
i++;
}
else if (arg.Equals("-log", StringComparison.OrdinalIgnoreCase))
{
logPath = Path.GetFullPath(nextArg);
i++;
}
}
}
return new CommandLineArgs(
processCommandLine,
noWait,
stdinPath,
stdoutPath,
stderrPath,
logPath);
}
public override string ToString()
{
return string.Format(
"CommandLineArgs(ProcessCommandLine={0}, NoWait={1}, StdinPath={2}, StdoutPath={3}, StderrPath={4}, LogPath={5})",
ProcessCommandLine,
NoWait,
StdinPath,
StdoutPath,
StderrPath,
LogPath);
}
}
internal class NoGuiLogger : IDisposable
{
private FileStream _fs;
private StreamWriter _sw;
public NoGuiLogger(string path)
{
if (string.IsNullOrEmpty(path) || !Directory.Exists(Path.GetDirectoryName(path)))
{
return;
}
_fs = File.Open(path, FileMode.Append, FileAccess.Write, FileShare.Read);
_sw = new StreamWriter(_fs, new UTF8Encoding(false));
}
public void Log(string msg, params object[] args)
{
if (_sw == null)
{
return;
}
string timeStamp = string.Format("{0} - ", DateTime.Now.ToString("hh:mm:ss.f"));
_sw.WriteLine(timeStamp + string.Format(msg, args));
_sw.Flush();
}
public void Dispose()
{
if (_sw != null) _sw.Dispose();
if (_fs != null) _fs.Dispose();
GC.SuppressFinalize(this);
}
~NoGuiLogger() { Dispose(); }
}
'@
@zett42
Copy link

zett42 commented Apr 10, 2025

Here is an extended version of this utility that waits for the child process to exit and returns its exit code. It also includes improved error handing.
https://stackoverflow.com/a/79566616/7571258

@jborean93
Copy link
Author

jborean93 commented Apr 11, 2025

@zett42 thanks for the link, the reason why I never went with the .NET Process class is because it didn't support ProcessStartInfo.WindowStyle unless you used ShellExecute = true which then has its own set of problems. .NET did change this in .NET 8 (with a PR I sent through) but as this is being compiled with .NET Framework it won't pick up the new changes. The CreateNoWindow argument corresponds to CREATE_NO_WINDOW in the process creation args and while it won't spawn the console window it might have some unexpected consequences if the console application then tries to use anything in the console and expects it to just exist. Plus using CreateProcess directly gives me finer control over how I can spawn the process and in the future I could more easily set the stdio handles rather than rely on .NET's limited subset of control over that.

This can certainly be expanded to support waiting for the child process and returning the rc, it's the reason why I added -- to separate future options for NoGui.cs vs arguments to pass through to the new process I just never got around to it. Error handling is another thing that would also be nice but I didn't want to try and figure out how to get that data back in a nice way vs just popping up a msg box but people can certainly change it up if they wish.

I'll try and update it so it will wait and passthrough the rc by default and have an option to just spawn and end. Keep in mind if you did Start-Process NoGui.exe -ArgumentList '-- ...' -Wait it would wait for any child process spawned anyway, you just wouldn't be able to get the RC.

Edit: It has been expanded to wait and set the RC as the child process' RC by default. You can do NoGui.exe -nowait -- ... to go back to the old behaviour. It also inherits the stdio handles if present but can also be overridden with the -stdin, -stdout, and -stderr arguments if you cannot set the stdio when calling NoGui.exe.

@zett42
Copy link

zett42 commented Apr 11, 2025

Ahh, I wasn't aware of the WindowStyle / CreateNoWindow problematic. I've added a related note to my SO answer and renamed to NoConsole.exe, to avoid appearing like a fork of your Gist. Appreciate the enhancements to NoGui.

@jborean93
Copy link
Author

Sounds great, using the message boxes in your version is a nice way to display the errors in the exe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment