5 января 2015 г.

Почему вам не следует использовать ShellExecute(Ex)

В прошлый раз мы узнали, почему вам никогда не следует использовать функцию ShellExecute.

В этот раз я расскажу вам о том, почему вам не следует использовать функцию ShellExecuteEx.

Заголовки этого и предыдущего постов выбраны крайне тщательно. Предыдущий пост говорил о том, что если вы пишете код в 1995 году или позднее, то вы не должны использовать функцию ShellExecute. Поскольку на дворе у нас 2015 год, то это означает, что ваш код вообще никогда не должен использовать ShellExecute. В заголовке же этого поста отсутствует слово "никогда", что намекает на то, что использовать ShellExecuteEx иногда можно. Кроме того, в заголовке используется двойное наименование, обозначая как функцию ShellExecute, так и функцию ShellExecuteEx - это говорит о том, что смысл не в конкретной функции, а в логических действиях, которые они выполняют.

Итак, в чём же проблема?

Суть проблемы

Очень часто начинающие программисты используют функцию ShellExecute для прямого запуска программ. Иными словами, они делают что-то такое:
ShellExecute(Handle, 'open', 'notepad', nil, nil, SW_SHOWNORMAL);
Хорошо, я надеюсь, они прочитали предыдущую статью и стали делать так:
ShellExecute(0, '', 'notepad');
(этот пример кода использует функцию-обёртку из предыдущей статьи; если вы не читали предыдущую статью, то считайте, что этот код эквивалентен вызову ShellExecuteEx с правильной обработкой ошибок, COM и асинхронных операций).

Да, так стало лучше, но проблема от этого не исчезла. Что же это за проблема?

Дело в том, что ShellExecute(Ex) (здесь и далее я буду употреблять комбинированное название функций для обозначения обеих функций) является функцией Оболочки Windows (Проводника/Explorer), которая предназначена для открытия файла в ассоциированной с ним программе - т.е. в той, которую назначил пользователь для данного типа файлов. Заметьте, что эта цель отлична от "запуска явно указанной программы".

Код выше работает по той простой причине, что действие по умолчанию для .exe-файлов - это их запуск.

Решение проблемы

Разумеется, чтобы просто запустить программу, вам вовсе не нужно открывать программу в ассоциированной программе. Вам нужно явно её запустить - и для этого используется функция CreateProcess, а не ShellExecute(Ex).

Что не так с ShellExecute?

Но почему бы не использовать ShellExecute(Ex) для запуска программ? Вот несколько причин (некоторые из причин применимы не всегда):
  1. Тяжеловесность. ShellExecute(Ex) намного тяжеловеснее CreateProcess, поскольку ей нужны многократные чтения реестра для поиска и разрешений ассоциаций, проверки override-ов и т.п. (насколько всё плохо, вы можете оценить из этой статьи - см. раздел "The Nitty Gritty" и ниже). Кроме того, ShellExecute(Ex) - функция Оболочки (импортируется из shell32.dll), а CreateProcess - функция ядра (импортируется из kernel32.dll). Иными словами, в компактных и/или не визуальных программах вы тащите к себе Оболочку (shell32.dll) целиком - ради всего одной функции, без которой можно и так обойтись;
  2. Уязвимость. ShellExecute(Ex) принимает только полную командную строку, в то время как CreateProcess позволяет явно указать имя программы и отдельно - командную строку.

    Проблема здесь в том, что если вы указываете только командную строку, то при неаккуратной расстановке кавычек вы можете создать в своём коде уязвимость или баг.

    И хотя вы определённо можете сделать всё корректно с одной только командной строкой, идея здесь в том, что тут легко ошибиться - поэтому лучше бы использовать вариант, где имя программы можно указать явно, тогда мы гарантированно защитимся от ошибок;
  3. Меньшая гибкость - поскольку ShellExecute(Ex) не предназначена для запуска программ, то у неё отсутствуют многие специфичные возможности. В частности, перенаправление ввода-вывода. Таким образом, если вы привыкли использовать ShellExecute(Ex) для запуска программ, и случилась нестандартная ситуация - вы не знаете, что делать. А если вы используете CreateProcess, то вы можете адаптировать/подправить код.

    Конечно, функция ShellExecuteEx даёт некоторые дополнительные возможности по сравнению с ShellExecute (например, ожидание завершения запущенной программы) - но её возможности далеки от полноценной CreateProcess;
  4. Вы не можете запустить программу, если она имеет нестандартное расширение - поскольку ShellExecute(Ex) не запускает программу, а открывает её в ассоциированной программе. Ассоциация выполняется по расширению файла. Таким образом, если файл не будет иметь расширение .exe (или .com), то запуск программы через ShellExecute невозможен (эта операция возможна для ShellExecuteEx, если вы используете переопределение типа файла). В то же время CreateProcess не смотрит на ассоциации файлов, а просто запускает программу - поэтому ему без разницы, имеет ли программа расширение .exe, .dat, .MyFile или иное;
  5. Иногда пользователь может назначить другую программу для открытия .exe файлов (по ошибке, конечно же). Или за него это может сделать какая-то бяка. В этом случае ShellExecute(Ex) будут использовать переопределённые ассоциации для .exe файлов и не смогут запустить программу. CreateProcess же запустит программу в любом случае, поскольку не смотрит на ассоциации;
  6. Зависимость от COM. Функция ShellExecute(Ex) - это функция Оболочки. Откуда следует, что для работы ShellExecute(Ex) необходима инициализация COM. Хотя в главном потоке VCL приложения Delphi делает это за вас автоматически, это не будет работать в других случаях (фоновых потоках). Отсюда же следует, что ShellExecute может не работать в MTA-окружениях. Знаете ли вы вообще, что такое инициализация COM и как она правильно делается?

    Да, если вы читали предыдущую статью, то вы в курсе, как нужно делать правильно. Кроме того, по умолчанию инициализация COM не обязательна именно для .exe файлов (ну, если только вы не будете запускать их с элевацией - как раз это делается через COM). Но для этого надо хотя бы знать про такую особенность, чтобы не впадать в панику при виде сообщения "Обращение к CoInitialize из текущего потока не производилось.".

    Функция CreateProcess - это функция ядра, она не требует знания особенностей Оболочки Windows;
  7. Аналогично, ShellExecute(Ex), являясь функциями Оболочки, требуют цикла выборки сообщений, и вам нужно делать специальную обработку для случаев, когда этого цикла у вас нет.

    Как и выше, это не проблема с .exe-файлами, но идея в том, что бездумно используя ShellExecute(Ex) вместо простейшей CreateProcess, вы не подумаете про ситуацию, в которой это действительно важно;
  8. Чтобы правильно сделать обработку ошибок для ShellExecute, вам нужно написать примерно такой код:
    var
      ErrorCode: Integer;
    begin
      ErrorCode := Integer(ShellAPI.ShellExecute(Handle, 'open', 'notepad', nil, nil, SW_SHOWNORMAL));
      if ErrorCode <= HINSTANCE_ERROR { = 32 } then
      begin
        case ErrorCode of
          0: Application.MessageBox(PChar('The operating system is out of memory or resources.'), 'Error', MB_OK or MB_ICONERROR);
          ERROR_FILE_NOT_FOUND: Application.MessageBox(PChar('The specified file was not found.'), 'Error', MB_OK or MB_ICONERROR);
          ERROR_PATH_NOT_FOUND: Application.MessageBox(PChar('The specified path was not found.'), 'Error', MB_OK or MB_ICONERROR);
          ERROR_BAD_FORMAT: Application.MessageBox(PChar('The .exe file is invalid (non-Win32 .exe or error in .exe image).'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_ACCESSDENIED: Application.MessageBox(PChar('The operating system denied access to the specified file.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_ASSOCINCOMPLETE: Application.MessageBox(PChar('The file name association is incomplete or invalid.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_DDEBUSY: Application.MessageBox(PChar('The DDE transaction could not be completed because other DDE transactions were being processed.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_DDEFAIL: Application.MessageBox(PChar('The DDE transaction failed.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_DDETIMEOUT: Application.MessageBox(PChar('The DDE transaction could not be completed because the request timed out.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_DLLNOTFOUND: Application.MessageBox(PChar('The specified DLL was not found.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_FNF: Application.MessageBox(PChar('The specified file was not found.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_NOASSOC: Application.MessageBox(PChar('There is no application associated with the given file name extension. This error will also be returned if you attempt to print a file that is not printable.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_OOM: Application.MessageBox(PChar('There was not enough memory to complete the operation.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_PNF: Application.MessageBox(PChar('The specified path was not found.'), 'Error', MB_OK or MB_ICONERROR);
          SE_ERR_SHARE: Application.MessageBox(PChar('A sharing violation occurred.'), 'Error', MB_OK or MB_ICONERROR);
        else
          Application.MessageBox(PChar(Format('Unknown Error %d', [ErrorCode])), 'Error', MB_OK or MB_ICONERROR);
        end;
        Exit;
      end;

    Насколько проще выглядит правильная обработка ошибок для CreateProcess:
    Win32Check(CreateProcess('notepad.exe', 'notepad.exe', nil, nil, False, 0, nil, nil, SI, PI));

Как правильно делать

Итак, когда же вам нужно использовать эти разные функции?
  • Никогда не используйте ShellExecute;
  • Никогда не используйте WinExec;
  • Используйте CreateProcess, если вы хотите запустить конкретную программу (имя вам известно);
  • Используйте CreateProcess, если вы хотели использовать ShellExecute(Ex) и обнаружили, что вы передаёте имя исполняемого файла в третий параметр (например: ShellExecute(Handle, nil, 'cmd.exe', nil, nil, SW_SHOW));
  • Используйте CreateProcess, если вы не знаете имя программы, но точно знаете, что это программа (например, имя программы приходит из файла конфигурации);
  • Используйте CreateProcess, если у вас есть не имя программы, а командная строка;
  • Используйте ShellExecuteEx, если вам нужно открыть файл, не являющийся программой (например, архив, документ, музыку);
  • Используйте ShellExecuteEx, если вам нужно запустить программу с элевацией (с использованием действия "runas");
  • Также используйте ShellExecuteEx с "runas", если CreateProcess вернул ERROR_ELEVATION_REQUIRED (= 740);
  • Используйте ShellExecuteEx, если вам нужно открыть файл, но вы не знаете, что это за файл (например, его имя вводится/указывается пользователем);
  • Используйте ShellExecuteEx, если вам нужно открыть гиперссылку (http или mailto);
  • Используйте ShellExecuteEx, если вы хотите подражать оболочке (например, пишете файловый менеджер);
  • Используйте ShellExecuteEx с SEE_MASK_INVOKEIDLIST для выполнения "динамических" действий, определяемых обработчиками контекстного меню.

Говоря совсем кратко: CreateProcess - для запуска программ (исключая случаи с элевацией), ShellExecuteEx - для открытия файлов и запуска программ с элевацией.

См. также: решение проблем CreateProcess.

P.S. Примечание: если вы используете ShellExecuteEx с действием "runas" для запуска процесса с элевацией, то вам нужно передавать корректный описатель окна (HWND): он будет использоваться для идентификации вашего процесса как приложения первого плана. Если же вы его не укажете, то ваше приложение будет считаться фоновым приложением. В этом случае запрос UAC на повышение прав не будет показан на экране сразу, а появится в свёрнутом (и мигающем) виде на панели задач.

12 комментариев :

  1. Сань, подкорректируй маленько магическую констаннту 32.
    Т.к. ShellExecute возвращает HINSTANCE, то и писать надо:
    if ErrorCode <= HINSTANCE_ERROR then

    ОтветитьУдалить
  2. Подправил, спасибо.

    32 указано в документации, упоминания HINSTANCE_ERROR там нет. Поэтому константу вставил, а литерал заключил в коммент.

    ОтветитьУдалить
  3. Здравствуйте,

    Использую ShellExecute(0, '', 'путькфайлу') для запуска ярлыков (.lnk) и ссылок (.url). В случае если ярлык ссылается на отсутствующий файл или в ссылке использован незарегистрированный в системе протокол ShellExecuteEx возвращает ошибку или показывает диалог отличный от диалога Проводника.
    Для ярлыка правильный диалог можно получить воспользовавшись IShellLink.Resolve.
    Возможно ли возложить обработку таких случаев, со всеми вытекающими диалогами, на Проводник ?

    Мне известна как минимум одна программа которая в таких случаях ведёт себя аналогично Проводнику. Вероятно она является расширением Shell тк не имеет собственного процесса или службы. Является ли это основанием для поведения аналогичного Проводнику в таких случаях как выше ?

    ОтветитьУдалить
  4. Давайте расставим все точки над i.

    Во-первых, IShellLink.Resolve - это НЕ способ показывать диалог. Это способ исправлять испорченный ярлык. Диалог - это лишь последняя ступень в алгоритме исправления данных, когда автоматические методы не дали результатов.

    Во-вторых, вы столкнулись с проблемой, о которой я говорю в статье. Вы пытаетесь использовать общее решение (ShellExecute/Ex) для решения частной проблемы. В статье под частной проблемой я использую "запуск программы", а в вашем случае частная проблема - это "исправление испорченного ярлыка". Общее решение ничего не знает про частные случаи, поэтому частные случаи вам нужно обрабатывать самостоятельно.

    Если вы встречаетесь только с частными случаями (по статье: ваш код запускает только программы; ваш случай: вы открываете только ярлыки), то вам не нужно использовать для этого общее решение (ShellExecute/Ex) - о чём и идёт речь в статье. Используйте частное решение.

    Если вы встречаетесь с различными случаями (вы открываете и файлы и ярлыки; иными словами, вы открываете любые файлы, но среди них могут быть ярлыки), но хотите сделать специальную обработку некоторых случаев, добавив им дополнительную функциональность, то вам нужно это делать самостоятельно.

    В частности, вам нужно определить, является ли файл ярлыком, и если да - то вызывать ваш код по обработке частного случая (IShellLink.Resolve), а если нет - то общего (ShellExecute/Ex).

    Чтобы определить, является ли файл ярлыком, вам нужно извлечь его расширение (например, '.lnk'), прочитать значение по умолчанию для HKCR\.lnk, затем проверить наличие (пустых) данных HKCR\lnkfile\IsShortcut: если IsShortcut есть, то файл - ярлык, если IsShortcut нет, то файл - не ярлык. Разумеется, вместо .lnk и lnkfile нужно подставить расширение файла и прочитанное из реестра значение типа файла.

    ОтветитьУдалить
  5. Спасибо,
    Буду действовать так.
    Однако остаётся чувство, что должен быть способ дать Проводнику команду на запуск любого файла, а дальше пускай Проводник сам решает как обрабатывать ошибки и какие диалоги показывать пользователю.

    Результат запуска, в виде числа для дальнейшей обработки, не нужен.

    ОтветитьУдалить
  6. Сам же Проводник и пользуется IShellLink.Resolve, равно как и другие файловые менеджеры (типа, Total Commander). Это можно увидеть, проанализировав стек вызовов. Например, в стеке вызовов Проводника нет никакой "типа ShellExecute, но чтоб ещё .lnk исправляла", зато есть CShellLink._InvokeCommandAsync (вероятно, аналог IContextMenu.InvokeCommand), CShellLink._Resolve (видимо, класс, который реализует IShellLink.Resolve) и CLinkResolver.Resolve.

    Я могу ошибаться, т.к. не эксперт в Оболочке.

    ОтветитьУдалить
  7. Согласен чудес не бывает. Но я бы хотел переложить ответственность за нативный отклик на Оболочку, а не делать код подражающий ей.
    ps. IShellLink не работает с .url. Нахожусь в поиске решения.

    ОтветитьУдалить
  8. Смотрим MSDN. ShellExecute не отмечена как устаревшая.

    Minimum supported client
    Windows XP [desktop apps only]
    Minimum supported server
    Windows 2000 Server [desktop apps only]

    Ее поддерживают и меняют реализацию, что бы соответствовать изменениям архитектуры ОС. Если не надо тонкого управления процессами, то ShellExecute то, что вам надо.

    ОтветитьУдалить
    Ответы
    1. Базара нет, ShellExecute - "cutting edge technology".

      Удалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.