2

I want to run a command that the user defined in an INI file.

The commands can be EXE files, or other files (e.g. DOC files) and parameters should be allowed.

Since WinExec() can handle arguments (e.g. "cmd /?"), but ShellExec() can handle Non-EXE files (e.g. "Letter.doc"), I am using a combination of these both.

I am concerned about future Windows versions, because WinExec() is deprecated, and even from the 16 bit era.

Here is my current function:

procedure RunCMD(cmdLine: string; WindowMode: integer);

  procedure ShowWindowsErrorMessage(r: integer);
  begin
    MessageDlg(SysErrorMessage(r), mtError, [mbOK], 0);
  end;

var
  r, g: Cardinal;
begin
  // We need a function which does following:
  // 1. Replace the Environment strings, e.g. %SystemRoot%  --> ExpandEnvStr
  // 2. Runs EXE files with parameters (e.g. "cmd.exe /?")  --> WinExec
  // 3. Runs EXE files without path (e.g. "calc.exe")       --> WinExec
  // 4. Runs EXE files without extension (e.g. "calc")      --> WinExec
  // 5. Runs non-EXE files (e.g. "Letter.doc")              --> ShellExecute
  // 6. Commands with white spaces (e.g. "C:\Program Files\xyz.exe") must be enclosed in quotes. 

  cmdLine := ExpandEnvStr(cmdLine);

  // TODO: Attention: WinExec() is deprecated, but there is no acceptable replacement
  g := WinExec(PChar(cmdLine), WindowMode);
  r := GetLastError;
  if g = ERROR_BAD_FORMAT then
  begin
    // e.g. if the user tries to open a non-EXE file
    ShellExecute(0, nil, PChar(cmdLine), '', '', WindowMode);
    r := GetLastError;
  end;
  if r <> 0 then ShowWindowsErrorMessage(r);
end;

function ExpandEnvStr(const szInput: string): string;
// http://stackoverflow.com/a/2833147/3544341
const
  MAXSIZE = 32768;
begin
  SetLength(Result, MAXSIZE);
  SetLength(Result, ExpandEnvironmentStrings(pchar(szInput),
    @Result[1],length(Result)));
end;

Microsoft recommends using CreateProcess(), but I do not accept it as a real replacement for WinExec().

For example, given following command line:

"C:\Program Files\xyz.exe" /a /b /c

Since ShellExecute() and CreateProcess() require a strict separation of command and arguments, I would have to parse this string myself. Is that really the only way I can go? Has someone written a public available code featuring this functionality?

Additional note: The process should not be attached to the caller. My program will close, right after the command has started.

Daniel Marschall
  • 3,739
  • 2
  • 28
  • 67
  • 2
    Note that CreateProcess does *not* require you to separate the arguments from the command. Check the documentation. The recommended practice is to pass `NULL` for the first argument, and the entire command line in the second argument. – Harry Johnston Sep 26 '15 at 23:26
  • 2
    "Parse this string myself" - well not really. All you need to do is find the first non-quoted space to know where the command ends and the arguments begin. `ShellExecuteEx` can do all that you need. – Jonathan Potter Sep 26 '15 at 23:28
  • @HarryJohnston I tried it with CreateProcess(), but it doesn't seem to accept non-EXE files. For example, an INI file won't open with its associated application: http://pastebin.com/ye7XMSCm . – Daniel Marschall Sep 27 '15 at 01:31
  • 1
    Neither does WinExec, does it? But Jonathan is right, it isn't difficult to split the command string into the two parts needed for ShellExecute or ShellExecuteEx. – Harry Johnston Sep 27 '15 at 02:18
  • CreateProcess replaces WinExec. The documentation is clear on the point. Your error checking is badly broken. ShellExecute doesn't supporl error checking properly anyway. Use ShellExecuteEx. More attention to the documentation is needed. – David Heffernan Sep 27 '15 at 08:10
  • @HarryJohnston WinExec() also runs *.doc files, like ShellExecute(), as well as arguments; this is why I tried to do the combination. – Daniel Marschall Sep 27 '15 at 20:47

2 Answers2

4

CreateProcess() is the replacement for WinExec(). The documentation explicitly states as much.

And BTW, the error handling in your original code is completely wrong. You are misusing GetLastError(). In fact, neither WinExec() nor ShellExecute() even report errors with GetLastError() to begin with. So, even if WinExec() or ShellExecute() are successful (and you are not even checking if ShellExecute() succeeds or fails), you risk reporting random errors from earlier API calls.

Try something more like this:

procedure RunCMD(cmdLine: string; WindowMode: integer);

  procedure ShowWindowsErrorMessage(r: integer);
  var
    sMsg: string;
  begin
    sMsg := SysErrorMessage(r);
    if (sMsg = '') and (r = ERROR_BAD_EXE_FORMAT) then
      sMsg := SysErrorMessage(ERROR_BAD_FORMAT);
    MessageDlg(sMsg, mtError, [mbOK], 0);
  end;

var
  si: TStartupInfo;
  pi: TProcessInformation;
  sei: TShellExecuteInfo;
  err: Integer;
begin
  // We need a function which does following:
  // 1. Replace the Environment strings, e.g. %SystemRoot%  --> ExpandEnvStr
  // 2. Runs EXE files with parameters (e.g. "cmd.exe /?")  --> WinExec
  // 3. Runs EXE files without path (e.g. "calc.exe")       --> WinExec
  // 4. Runs EXE files without extension (e.g. "calc")      --> WinExec
  // 5. Runs non-EXE files (e.g. "Letter.doc")              --> ShellExecute
  // 6. Commands with white spaces (e.g. "C:\Program Files\xyz.exe") must be enclosed in quotes. 

  cmdLine := ExpandEnvStr(cmdLine);
  {$IFDEF UNICODE}
  UniqueString(cmdLine);
  {$ENDIF}

  ZeroMemory(@si, sizeof(si));
  si.cb := sizeof(si);
  si.dwFlags := STARTF_USESHOWWINDOW;
  si.wShowWindow := WindowMode;

  if CreateProcess(nil, PChar(cmdLine), nil, nil, False, 0, nil, nil, si, pi) then
  begin
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
    Exit;
  end;

  err := GetLastError;
  if (err = ERROR_BAD_EXE_FORMAT) or
     (err = ERROR_BAD_FORMAT) then
  begin
    ZeroMemory(@sei, sizeof(sei));
    sei.cbSize := sizeof(sei);
    sei.lpFile := PChar(cmdLine);
    sei.nShow := WindowMode;

    if ShellExecuteEx(@sei) then Exit;
    err := GetLastError;
  end;

  ShowWindowsErrorMessage(err);
end;
Remy Lebeau
  • 555,201
  • 31
  • 458
  • 770
  • Thank you very much for this nice solution! GetLastError did work on my system, but it is probably only an undocumented behavior which should be avoided, of course. I found out that `CreateProcess()` returns `BAD_EXE_FORMAT` instead of `BAD_FORMAT` - surprisingly, `SysErrorMessage(BAD_EXE_FORMAT)` returns an empty string (on German Windows 10) . – Daniel Marschall Sep 27 '15 at 17:59
  • By the way, why did you use `UniqueString` at Unicode only? I never fully understood `UniqueString()` [even after reading the Embarcadero documentation] - as far as I understood, `ExpandEnvStr()` did create a string [=WideString], and by returning the string as function result, the Reference Counter should be still 1? – Daniel Marschall Sep 27 '15 at 18:00
  • 1
    GetLastError doesn't work anywhere as written in your code. – David Heffernan Sep 27 '15 at 20:05
  • @DavidHeffernan I am talking about the code of Remy. CreateProcess delivers the correct GetLastError , but SysErrorMessage of error 193 (BAD_EXE_FORMAT) is empty in the German version of Windows. – Daniel Marschall Sep 27 '15 at 20:36
  • @rinntech: If `SysErrorMessage(ERROR_BAD_EXE_FORMAT)` does not return an error string, you could call `SysErrorMessage(ERROR_BAD_FORMAT)` as a substitute. As for `UniqueString()`, `CreateProcessW()` requires the second parameter to point at writable memory as it may need to tweak the command line, so any `string` passed in must have a reference count of 1 to avoid crashes and unwanted side effects. `ExpandEnvStr()` does return a `string` with refcount=1, so you could omit the `UniqueString()` in this situation. `CreateProcessA()` does not have that requirement, thus the `{$IFDEF UNICODE}`. – Remy Lebeau Sep 27 '15 at 20:59
  • 1
    My comment was in response to your saying *GetLastError did work on my system, but it is probably only an undocumented behavior which should be avoided, of course.* The error checking in the code in your question has never been correct, and has never worked. If it seemed to work that's by coincidence. – David Heffernan Sep 28 '15 at 07:12
2

Although similar ShellExecute and CreateProcess serve a different purpose.

ShellExecute can open non executable files. It looks up the information in the registry for the associated program for the given file and will execute it. ShellExecute is also great for launching the default web browser and you can pass a URL to it it.

So if you were to pass an TXT file to ShellExecute it would open the associated program such as notepad. However it would fail with CreateProcess.

CreateProcess is a lower level function, that allows you to have better control over the input and output from the the process. For example you can call command line programs that have text output with CreateProcess and capture that output and react according.

Given the concerns you have you will need to use ShellExecute. You will however need to split the command from the parameters. This is would be the first non escaped whitespace character.

I personally rarely call ShellExecute or CreateProcess directly. I tend to use on of the following functions from the JCL that wrap these functions.

JclMiscel.pas

  • CreateDosProcessRedirected
  • WinExec32
  • WinExec32AndWait
  • WinExecAndRedirectOutput
  • CreateProcessAsUser
  • CreateProcessAsUserEx

JclShell.pas

  • ShellExecEx
  • ShellExec
  • ShellExecAndWwait
  • RunAsAdmin

JclSysUtils.pas

  • Execute (8 Overloaded versions and is the one I use the most)
Robert Love
  • 12,447
  • 2
  • 48
  • 80
  • Thank you for this recommendation. For the current project, I want to use as less third-party-code as possible, but I will look at JCL in the next projects, as I often heared that it has food functionalities; +1 – Daniel Marschall Sep 27 '15 at 21:07