Как известно, контрол WebBrowser это просто обертка над ActiveX компонентом Internet Explorer. Следовательно он предоставляет доступ к полноценному layout-движку со всеми современными плюшками. А раз так, то попробуем (сам не знаю правда зачем) на его основе сделать пользовательский интерфейс для обычного windows-приложения.

Можно, конечно, было бы запустить в этом же процессе мини веб-сервер (на HttpListener например) и ловить запросы через него, но это слишком просто, скучно и неспортивно. Попробуем обойтись без сетевых компонентов, just for fun.

Итак, задача проста — необходимо перехватывать отправку HTML-форм и выводить новый HTML-документ, сгенерированный логикой приложения в зависимости от POST-параметров.

Прежде всего нам понадобится Windows Forms Application с одной единственной формой на которой будет один единственный контрол — WebBrowser занимающий все пространство.

Для примера рисуем пару простых страниц интерфейса (с минимальным содержимым для краткости, а так скрипты и стили добавлять по вкусу).

Первая страница:

<!DOCTYPE html>
<html>
<head><meta http-equiv="X-UA-Compatible" content="IE=11"></head>
<body>
    <form method="post">
        <input type="text" name="TEXT1" value="Some text" />
        <input type="submit" value="Open page 2" name="page2" />
    </form>
</body>
</html>

Вторая страница:

<!DOCTYPE html>
<html>
<head><meta http-equiv="X-UA-Compatible" content="IE=11"></head>
<body>
    <div>%TEXT1%</div>
    <form method="post">
        <input type="submit" value="Back to page 1" name="page1" />
    </form>
</body>
</html>

Примечание: X-UA-Compatible необходим для корректного отображения некоторых стилей, в данном примере они не используются, но проблема есть. Не буду вдаваться в детали, но без этого компонент рисует страницы в режиме совместимости с чем то очень старым, со всеми вытекающими последствиями.

WebBrowser предоставляет несколько событий, среди которых есть например Navigating, которое срабатывает перед тем как отправить форму или перейти по ссылке.
Практически то что нужно, но это событие не предоставляет никакой информации о post-параметрах. GET-параметры использовать не получится поскольку у наших HTML-форм атрибут action отсутствует, что приведет к тому что в качестве URL всегда будет about:blank и никакой информации о GET параметрах не будет.
Для получения более подробной информации о запросе надо подписаться на событие BeforeNavigate2 (подробнее тут) у внутреннего COM-объекта браузера, благо он доступен через свойство ActiveXInstance.
Сделать это проще всего через dynamic, чтобы не возиться с объявлением COM-интерфейсов:
Объявляем делегат:

private delegate void BeforeNavigate2(object pDisp, string url, int Flags, string TargetFrameName, byte[] PostData, string Headers, ref bool Cancel);

Затем подписываемся на событие (в конструкторе формы у WebBrowser свойство ActiveXInstance будет null, потому это лучше сделать после того как окно загрузится, т.е. в OnLoad например):

((dynamic)webBrowser.ActiveXInstance).BeforeNavigate2 += new BeforeNavigate2(OnBeforeNavigate2);

Итак, в PostData лежат post-параметры в виде строки состоящей из пар ключ=значение объединенных через &. Разделяем их и укладываем в словарь:

var parameters = (PostData == null ? string.Empty : Encoding.UTF8.GetString(PostData))
    .Split('&')
    .Select(x => x.Split('='))
    .Where(x => x.Length == 2)
    .ToDictionary(x => WebUtility.UrlDecode(x[0]), x => WebUtility.UrlDecode(x[1]));

Кроме того, в этом обработчике лучше отменить действие через параметр Cancel, чтобы лишний раз не попадать на about:blank.

Имея параметры генерируем текст новой страницы. Сюда теоретически прикручивается какой-н менеджер обработчиков, выбирающий необходимый обработчик в зависимости от параметров, которые по каким-н шаблонам будут собирать страницы из кусочков, но для краткости пока для примера просто по кнопке page1 откроем первую страницу, по кнопке page2 — вторую (имена кнопок в разметке указывать обязательно, иначе из post-параметров не определить какую именно кнопку нажали), а также заменим все строки в круглых скобках на значения параметров с такими именами:

        private static string Handler(IReadOnlyDictionary<string, string> parameters)
        {
            // do some useful stuff here 
            var newPage = "Not found";
            if (parameters.ContainsKey(nameof(page1)))
                newPage = page1;
            if (parameters.ContainsKey(nameof(page2)))
                newPage = page2;
            parameters.ToList().ForEach(x => newPage = newPage.Replace("{" + x.Key + "}", x.Value));
            return newPage;
        }

Полученную строку с HTML-текстом записываем в свойство DocumentText WebBrowser-а.
И тут же получим бесконечный цикл перезагрузки страницы, поскольку установка этого свойства спровоцирует новый вызов OnBeforeNavigate2.
Временно отписаться от этого события не получится, поскольку вызывается оно откуда то из цикла обработки сообщений уже после того как установка DocumentText возвращает управление.
Т.о. необходимо всегда игнорировать каждый второй вызов обработчика, поскольку первое срабатывание это отправка формы в результате действий пользователя, которое обрабатывать нужно, а второе — отображение результата которое обрабатывать не нужно. Для простоты будем в начале обработчика OnBeforeNavigate2 переключать bool переменную.

ignore = !ignore;
if (ignore)
    return;


И вот результат:



Это все что необходимо для минимального приложения. Но работать таким образом будет не все — за рамками осталось например получение данных о файлах для input type=«file», а также работа с XMLHttpRequest для корректной работы скриптов со всякими там ajax.

Исходный код
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Windows.Forms;

namespace TestHtmlUI
{
    internal sealed class MainForm : Form
    {
        private const string page1 = @"<!DOCTYPE html>
<html>
<head><meta http-equiv=""X-UA-Compatible"" content=""IE=11""></head>
<body>
    <form method = ""post"" >
        <input type=""text"" name=""TEXT1"" value=""Some text"" />
        <input type=""submit"" value=""Open page 2"" name=""" + nameof(page2) + @""" />
    </form>
</body>
</html>";

        private const string page2 = @"<!DOCTYPE html>
<html>
<head><meta http-equiv=""X-UA-Compatible"" content=""IE=11""></head>
<body>
    <div>{TEXT1}</div>
    <form method=""post"">
        <input type=""submit"" value=""Back to page 1"" name=""" + nameof(page1) + @""" />
    </form>
</body>
</html>";
        
        private delegate void BeforeNavigate2(object pDisp, string url, int Flags, string TargetFrameName, byte[] PostData, string Headers, ref bool Cancel);
        
        private readonly WebBrowser webBrowser = new WebBrowser { Dock = DockStyle.Fill };
        private bool ignore = true;

        private MainForm()
        {
            StartPosition = FormStartPosition.CenterScreen;
            Controls.Add(webBrowser);
            Load += (sender, e) => ((dynamic)webBrowser.ActiveXInstance).BeforeNavigate2 += new BeforeNavigate2(OnBeforeNavigate2);
            
            webBrowser.DocumentText = Handler(new Dictionary<string, string> { { nameof(page1), string.Empty } });
        }
        
        private void OnBeforeNavigate2(object pDisp, string url, int Flags, string TargetFrameName, byte[] PostData, string Headers, ref bool Cancel)
        {
            ignore = !ignore;
            if (ignore)
                return;
            Cancel = true;
            
            var parameters = (PostData == null ? string.Empty : Encoding.UTF8.GetString(PostData))
                .Split('&')
                .Select(x => x.Split('='))
                .Where(x => x.Length == 2)
                .ToDictionary(x => WebUtility.UrlDecode(x[0]), x => WebUtility.UrlDecode(x[1]));
            webBrowser.DocumentText = Handler(parameters);
        }

        [STAThread]
        private static void Main()
        {
            Application.EnableVisualStyles();
            Application.Run(new MainForm());
        }

        private static string Handler(IReadOnlyDictionary<string, string> parameters)
        {
            var newPage = "Not found";
            if (parameters.ContainsKey(nameof(page1)))
                newPage = page1;
            if (parameters.ContainsKey(nameof(page2)))
                newPage = page2;
            // do dome usefull stuff here 
            parameters.ToList().ForEach(x => newPage = newPage.Replace("{" + x.Key + "}", x.Value));
            return newPage;
        }
    }
}

Поделиться с друзьями
-->

Комментарии (27)


  1. impwx
    02.06.2016 10:14
    +1

    Кстати, не такое уж и «ненормальное программирование». В Visual Studio примерно таким же образом сделана авторизация через веб-страницу, замаскированную под диалоговое окно.


  1. harvester
    02.06.2016 12:28

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


    1. adasoft
      02.06.2016 12:57

      насколько мне помнится это когда то работа, но у меня не получилось… поигрался с мета тегами и получил интересную ситуацию — UA возвращал версию IE 7.0, но работали все фишки IE 10/11. В принципе этом меня устроило.

      Правда у меня ситуация была другая — мне надо было перенести Cordova приложение на Windows Desktop. Поэтому в приложение был добавлен еще и вебсервер и прилеплен механизм обмена данными. Под виндой работало, а вот под МакОС (использоваля PureBasic) то работало, то нет, то снова работало. Подозреваю, что необходимо было разность приложение, WebView и вебсервер по разным потокам…


      1. IvanPanfilov
        02.06.2016 13:56

        > еще и вебсервер и прилеплен механизм обмена данными

        а просто адаптировать для HTA на js нельзя было?


        1. adasoft
          02.06.2016 15:17

          Наверное можно было, но это разрушило бы идеологию — один код (Purebasic, html5/js) для всех платформ.

          Кстати да! Вспомнил «прилеплен механизм обмена данными» это как раз для встроенного WebControl. Для версии с вебсервером внутри приложения — обмен обычный через http get/post/put


    1. Einherjar
      02.06.2016 13:08

      Да, я читал об этом. Но считаю что по возможности лучше обходиться без записей в чужие/системные ключи реестра когда это возможно. Раз тег тоже решает проблему, то реестр можно не трогать. Но ключ реестра поможет при отображении не собственного текста из памяти а какой-то реальной веб-страницы из интернета, на содержимое которой повлиять невозможно.


      1. akhmelev
        02.06.2016 23:19

        Пишется воббще-то во вполне предназаченные именно для этого юзерские ветки
        https://msdn.microsoft.com/en-us/library/ee330720(v=VS.85).aspx

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


    1. boblenin
      02.06.2016 20:21

      Настройка есть, не всегда приложению туда дают лазить. Собственно и правильно делают. Одна настройка для всех.


      1. akhmelev
        02.06.2016 23:30

        Это не так. Совсем не так.


  1. dimka11
    02.06.2016 12:48

    Под Android куча приложений, использующих веб фреймфорки, странно, что для Windows этот подход совсем не используется


    1. Focushift
      02.06.2016 13:59
      +1

      Про кроссплатформенный Atom/Electron где только не писали, на котором сделан Visual Studio Code.


  1. Antelle
    02.06.2016 13:59

    О, я делал то же самое на C++ и кроссплатформенное. Такой подход себя не оправдывает из-за тормозов и глюков встроенного IE: electron быстрее, надёжнее и проще.


  1. boblenin
    02.06.2016 14:06
    +4

    WebBrowser компонент — это всегда IE причем зачастую неожиданной версии на машине у пользователя. IE сам по себе боль, а когда нужно под него писать не зная конкретную версию (а зачастую зная что лучшее на что можно надеяться это IE6 — enterprise для медицины или банков напр.) — то боль становится невыносимой.


    Даже если мы решаем, что наше приложение это .NET framework 4.0, то разброс версий IE которые будут представлены в системах клиента — это IE8 — IE11 причем на системах с разными способами рендера шрифтов (Win XP — Win 10), разными способами масштабирования.


    Когда нам надо было кусок нашего web приложения встроить в rich client на winforms, то повоевав с недельку с WebBrowser компонентом и поняв что доставшийся от контракторов backbone код под него не был заточен и фиг перезаточишь — решили пересесть на движок на базе чего-то другого firefox или chrome. Перепробовав многие из них остановились на CEFSharp. Да возни с его настройкой побольше, чем с WebBrowser control, сборка сложнее, но оно того стоило. Во-первых фиксированая версия браузера, которая не зависит от левой пятки админа в сети клиента. Во-вторых это chromium с родным V8 а значит JavaScript быстрый. В-третьих это WebKit/blink а значит и возможности по рендеренгу HTML повыше. Ну и все радости кастомизатора вроде получения событий и самостоятельная обработка сетевых запросов — в наличии.


    Мое мнение на основе горького опыта. WebBrowser Control — если рендерим что-то очень простое или строго для себя (для внутренних нужд компании), если что-то пойдет вовне — лучше использовать другие средства.


    1. JacobL
      02.06.2016 19:01

      На сколько увеличился размер вашего приложения из-за встраивания CEFSharp?


      1. boblenin
        02.06.2016 20:16

        Точных чисел не дам сейчас, но порядок — 100мб.


        1. JacobL
          03.06.2016 18:33

          Много. Я понимаю, что у нас сейчас не принято заботиться о размере дистрибутива, но когда приложение весит 20 мб, страшно не хочется встраивать в него браузер в 100. И мне не очень понятно, почему нету легких браузеров для встраивания.


          1. boblenin
            03.06.2016 21:03

            Сразу несколько тем в одной
            1) 100Мб может быть как много так и не много в зависимости от ситуации. Если у вас что-то вроде условнобесплатной утилитки, то пожалуй 100мб это серьезный барьер для пользователей. В нашей ситуации размер приложения был далеко не самым важным фактором.


            2) Легких браузеров (встраиваемых или нет) нет, потому что все движки довольно сложные. HTML/CSS сложный язык, а внутри еще и JavaScript, и flash, и воспроизведение кучи всяких медиа форматов, плюс шифрование, плюс ресурсы, плюс XML часто, плюс инструменты разработчика итд итп. Если попробуете все это сами запаковать в меньший объем — убедитесь что это не так-то просто.


            Если вам нужен только HTML + CSS рендерер, то есть другие и более легкие варианты. Тот же blink/webkit отдельно бывает под разные языки. Опять же если HTML у вас простой — то WebBrowser control вполне подойдет, если нужен JS — то где-то здесь была ссылка на статью, где автор все отдельно запиливал.


            Если же вам нужен просто интерфейс который можно динамически строить на каком-то декларативном языке, так тот же XAML, XUL, QML ну или миллион других опций в зависимости от языка и платформы. HTML/CSS — не лучший вариант (ибо слишком сложный формат и куча не нужного вам).


            Ну а серебряной пули, которая при этом еще и маленькая увы нет.


            1. Einherjar
              04.06.2016 01:57

              Если же вам нужен просто интерфейс который можно динамически строить на каком-то декларативном языке, так тот же XAML, XUL, QML

              Ну для .NET из коробки только XAML. Все остальное с костылями и следовательно заранее неизвестным количеством граблей на которые придется наступить. В этом плане на фоне прочих 'неродных' решений HTML пожалуй будет предсказуемее что ли. А так, теоретически, к примеру при наличии в проекте веб-частей, возможно использование суммарно на одну технологию меньше перекрыло бы 'не лучшесть' этого варианта.
              Но в любом случае я согласен что что-либо серьезное пока только на сторонних движках можно делать, ибо всегда чем меньше приложение зависит от внешних факторов тем лучше.


            1. JacobL
              04.06.2016 16:52
              +1

              Если попробуете все это сами запаковать в меньший объем — убедитесь что это не так-то просто.

              В этом я и не сомневался. Но вот почему бы не позволить делать кастомные сборки только с нужными функциями? Например, я увернен, что флеш мне не нужен и никогда не понадобится. То же самое с инструментами разработчика, воспроизведением медиа и тд.


              1. boblenin
                04.06.2016 19:59

                Так ведь вам никто и не запрещает делать кастомные сборки. Можете отдельно webkit взять, отдельно javascript, итд итп. Только собрать все это вместе и проинтегрировать — это примерно объем работы которым заняты разработчики современной opera или vivaldi. Ну это если вы хотите чтобы у вас в окошке крутились angluar или backbone приложения. А если вы хотите просто на html рисовать морду и всю логику на том же C# обрабатывать, то задача существенно проще.


  1. lek
    02.06.2016 20:59

    Есть более удобное решения для подобный приложений — http://sciter.com/ Оно специально заточено под разработку UI + кросплатформенное. Не знаю, есть ли биндинги для C#, но их можно всегда сделать. Я делал HTML-based UI на IE и на Sciter. Последний явно удобнее и приятнее. Плюс он гораздо компактнее всего остального, что доступно на сегодняшний день, меньше того же электрона в десятки раз.


  1. alexcl
    02.06.2016 21:24

    Еще для разработки десктопных приложений на C# с HTML интерфесйом можно использовать .Net Core + Electron. Получается что-то типа локального веб-сайта. В Asp.Net Core встроен хороший веб-сервер — Kestrel, с поддержкой шаблонного двжижка Razor, так что разработка UI идет довольно быстро.

    Эта пара фреймворков, конечно, заметно увеличивает размер приложения, добавляя мегабайт 50 к инсталятору. Зато приложение получается кросс-платформенным. Вот один из примеров — CatLight catlght.io


  1. BalinTomsk
    02.06.2016 22:21
    -1

    VS2013->New Project->Windows Desktop->WPF Browser Applicaiton?


    1. Einherjar
      02.06.2016 22:32
      +1

      И какое это имеет отношение к использованию HTML для создания пользовательских интерфейсов?


      1. BalinTomsk
        03.06.2016 23:15

        Самое прямое. Десктоп приложение написаное на C# завoрачивается в html.


        1. Einherjar
          03.06.2016 23:45

          Вы либо абсолютно не знаете что такое WPF Browser Applicaiton либо не поняли идею статьи.
          WPF Browser Applicaiton не заворачивается в html, такое приложение загружается браузером и запускается в отдельном процессе PresentationHost.exe, просто parent-окном является браузер. На такой «странице» с этим xbap-приложением HTML отсутствует в принципе.
          То что вы советуете диаметрально противоположно сути статьи. Суть — рисовать UI десктопного приложения на HTML _вместо_ XAML или WinForms, и встраивать в приложение браузер для его отрисовки. Подчеркну — десктопное приложение, с полными возможностями десктопных приложений и без неободимости использовать к-л сервера. Вы же советуете взять «десктопное» приложение с десктопной технологией UI (xaml ни в какой html при выполнении не трансформируется) и запихнуть его тем самым в браузер, причем непонятно зачем, поскольку никаких преимуществ по сравнению с обычным wpf приложением при использовании в качестве standalone-приложения это не даст, зато на ровном месте получаем кучу ограничений песочницы в которой оно выполняется и которые еще руками отключать придется.


          1. Antelle
            04.06.2016 00:09
            +1

            Я думаю, имелся в виду WinJS, а не WPF.