Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Web Application - Advanced Hosting of WebBrowser Control with ATL

0.00/5 (No votes)
14 Oct 2009 3  
Example of handling real-life challenges when hosting WebBrowser control: separate creation of hosting window and the control, keyboard shortcuts, control's dimensions, etc.
Microsoft Live Excel Application:

Click to open full image in new window.

Microsoft Live Calendar Application:

Click to open full image in new window.

Yahoo! Mail Application:

Click to open full image in new window.

Contents

Introduction

With the advent of modern sophisticated web applications, web browser became one of the most frequently used applications on our desktop computers. However, most of the modern browsers still maintain the traditional browser look: address bar, navigation buttons, web page tabs, search box, etc. None of these GUI elements are required while using many advanced web applications which replace the existing desktop counterparts. Typically, a user never navigates back and forth with navigation buttons, never uses the browser's main menu, and would prefer to have a separate window for an application. In short, most users want to have web applications as similar to their desktop big brethren as possible.

Unfortunately, currently only one browser somewhat supports a web application mimicking a desktop application: it is the Google Chrome browser. With Google Chrome, a user can take an arbitrary URL and create a Windows shortcut, which can be placed on Desktop, Start menu, Quick Launch bar, etc. Launching the shortcut will open a new streamlined browser window without the usual browser GUI elements. Here is the description of the Chrome Application shortcuts: Tabs and windows: Application shortcuts. The single drawback of the Chrome application is that clicking on links within an application will open them in a Google Chrome browser exclusively. A true desktop application should open web links in the default browser, whatever it is.

First Attempt: HTA Host

Microsoft Windows provides a wonderful undeservedly little-known technology: HTML Application, or HTA. HTA allows to run a regular HTML page with the security rights of a full fledged desktop application. With the help of HTA, it is possible to create desktop applications as cheap as creating web pages and writing JavaScript. No binary code or compilation is required. Here is an example of an HTML editor written solely with HTML and script: How to Create an HTML Editor Application.

In order to make HTA the host of an arbitrary URL, we need to create an HTML page with a single IFRAME element within it, which occupies all the available space on the page. In order to prevent a frame's content accessing an outside system, we need to specify an HTA-specific attribute: application="no" for the IFRAME element. Here is the HTML part of the WebApp HTA (the script part is omitted for clarity):

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
    <style>
        html, body
        {
            height: 100%
        }
    </style>
    <title>Web Application</title>
    <hta:application
        id="oHTA"
        icon="http://www.microsoft.com/windows/Framework/images/favicon.ico"
        innerBorder="no"
        scroll="no"
    />

    <script language="vbscript" type="text/vbscript">
    ...
    </script>

</head>
<body leftmargin="0" topmargin="0" 
        rightmargin="0" bottommargin="0">
    <iframe
        id="content"
        height="100%"
        width="100%"
        frameborder="0"
        application="no"
    />
</body>
</html>

The full source code of the HTA WebApp can be found at the link above.

The HTA web application provides a sufficient solution for the majority of users. However, it has several disadvantages, as well:

  • A new window is always opened in the new standalone Internet Explorer browser.
  • It is impossible to change the application icon at runtime.
  • It is impossible to catch title changes of the contents frame, so we need to poll the title from an outer document script.

Summary of Existing Solutions

Here is a summary of existing solutions, including the WebApp native client, for comparison:

Google Chrome HTA WebApp
New Window Opened in an existing instance of Google Chrome browser. Opened in a new instance of Internet Explorer browser even if already running instance exists. Opened in the default web browser according to the user's settings (new tab in existing instance, or new process).
Window Icon Site's favicon is used as a window icon automatically. Icon cannot be changed at runtime. Site's favicon can be used if specified explicitly in the HTA's source. Site's favicon is used as a window icon automatically.
Title Changes Title changes immediately. Title must be polled from the script. Title changes immediately.
Status bar No status bar. No status bar. Status bar can be switched on and off.

Second Attempt: C++ ATL Host

In order to acquire full control over the WebBrowser control, we need to host it ourselves. ATL framework is the natural choice for COM-intensive code. That's why the WebApp project uses ATL as a helping tool.

How ATL Hosts an ActiveX Control

ATL hosting of ActiveX controls is implemented primarily in two main classes: CAxWindow and CAxHostWindow. CAxWindow is what a developer should be using directly in his/her code. CAxHostWindow is an undocumented class, which implements the gory details of the ActiveX host. The CAxHostWindow instance is created behind the scenes by the CAxWindow class. The user can control the CAxHostWindow instance via the IAxWinHostWindow interface, which is obtainable through a CAxWindow::QueryHost call. Here is the execution flow of the hosted ActiveX control creation:

  • CAxWindow::CAxWindow() constructor runs
    • AtlAxWinInit() function is called
      • Two special window messages are registered: WM_ATLGETHOST, WM_ATLGETCONTROL
      • Special window classes are registered: AtlAxWinNN, AtlAxWinLicNN, where NN is the ATL version number. For example, AtlAxWin90. The window procedure for these classes is: AtlAxWindowProc.
  • User calls the CAxWindow::Create function
    • New window is created
    • Within the WM_CREATE handler of the AtlAxWindowProc procedure:
      • New CAxHostWindow COM object is created
      • CAxHostWindow object subclasses a newly created window
      • CAxHostWindow creates a hosted ActiveX control instance
      • CAxHostWindow sets itself as an OLE site for the hosted control

From now on, the user can access the hosted ActiveX control via a CAxWindow::QueryControl call.

Resizing Problem

The default ATL hosting implementation would be OK if there was no, one little problem: the WebBrowser control calls the host's IOleInPlaceSite::OnPosRectChange method with incorrect parameters. Suppose a webpage has the following script:

window.open("page.htm", null, "left=150,top=150,height=500,width=400");

Now, these window coordinates are not the same. Left and Top values are in screen coordinates, while Height and Width values are the size of the client area of the new window. Apparently, the WebBrowser control doesn't care about the difference and passes the new coordinates as is to its host. ATL's default implementation of the IOleInPlaceSite::OnPosRectChange method is as follows:

// CAxHostWindow::OnPosRectChange
STDMETHOD(OnPosRectChange)(LPCRECT lprcPosRect)
{
    ATLTRACE2(atlTraceHosting, 0, _T("IOleInPlaceSite::OnPosRectChange"));

    if (lprcPosRect==NULL) { return E_POINTER; }

    // Use MoveWindow() to resize the CAxHostWindow.
    // The CAxHostWindow handler for OnSize() will
    // take care of calling IOleInPlaceObject::SetObjectRects().

    // Convert to parent window coordinates for MoveWindow().
    RECT rect = *lprcPosRect;
    ClientToScreen( &rect );
    HWND hWnd = GetParent();

    // Check to make sure it's a non-top-level window.
    if(hWnd != NULL)
    {
        CWindow wndParent(hWnd);
        wndParent.ScreenToClient(&rect);
        wndParent.Detach ();
    }
    // Do the actual move.
    MoveWindow( &rect);

    return S_OK;
}

The above implementation is good for most of the cases. However, in our case, the code doesn't take into account the difference between the coordinates, and therefore moves the WebBrowser control window within its parent window instead of moving the parent window itself and adjusting the parent's client area.

How can we override the IOleInPlaceSite::OnPosRectChange method without changing ATL's source files? The answer is: by aggregating the CAxHostWindow control and by providing our own implementation of the IOleInPlaceSite interface. But we cannot aggregate the CAxHostWindow control yet, since it is created deep inside the ATL implementation of the AtlAxWindowProc procedure. So, we need to create a hosting window and the CAxHostWindow object instance separately, and explicitly provide our own host object as controlling the outer one.

Creating the Hosting Window and the Hosted Control Separately

The CBrowserHost class that maintains a hosting window for the WebBrowser control using the same approach as the CAxHostWindow class: instead of being a regular C++ class, CBrowserHost is a COM object, which implements its own COM interface for internal use. First of all, we declare the IBrowserHost COM interface:

struct IBrowserHost : public IUnknown
{
public: 
    virtual HRESULT STDMETHODCALLTYPE CreateHostWindow(
        /* [in] */ HWND hwndParent,
        /* [in] */ int nShowCmd,
        /* [in] */ bool bMainWindow) = 0;

    virtual HRESULT STDMETHODCALLTYPE GetWebBrowser(
        /* [out] */ SHDocVw::IWebBrowser2** ppWebBrowser) = 0;

    virtual HRESULT STDMETHODCALLTYPE SetUrl(
        /* [string][in] */ LPCWSTR pwszUrl) = 0;

    virtual HRESULT STDMETHODCALLTYPE PreTranslateMessage(
        /* [in] */ const MSG& msg) = 0;
};

Then, we make a static CBrowserHost::Create function to create object instances. First, this function creates a new instance of the BrowserHost COM control, then uses the IBrowserHost::CreateHostWindow method to create a hosting window and a WebBrowser control. Here is the execution flow of the BrowserHost creation:

  • The user calls the CBrowserHost::Create static method.
    • A new overlapped window is created.
    • CBrowserHost::_CreatorClass::CreateInstance is called and a new instance of BrowserHost is created. This instance implements a window procedure for the overlapped window.
      • Within the CBrowserHost::FinalConstruct method, we create an aggregated CAxHostWindow object.
    • IBrowserHost::CreateHostWindow method is called.
      • A new child window is created. This child window will host a WebBrowser control. The CAxHostWindow object will subclass this window.
      • IAxWinHostWindow::CreateControl("Shell.Explorer", ...) is called. The WebBrowser instance is created. The child window is subclassed.

Here are the relevant parts of the code:

// static
CBrowserHostPtr CBrowserHost::Create(
    HWND hwndParent,
    int nShowCmd,
    bool bMainWindow)
{
    CComPtr<IUnknown> ptrUnk;
    HRESULT hr = CBrowserHost::_CreatorClass::CreateInstance(
        NULL, IID_PPV_ARGS(&ptrUnk));
    ATLASSERT(SUCCEEDED(hr));

    CBrowserHostPtr ptrBrwHost = ptrUnk;
    if(ptrBrwHost)
    {
        hr = ptrBrwHost->CreateHostWindow(hwndParent, nShowCmd, bMainWindow);
        ATLASSERT(SUCCEEDED(hr));

        if(FAILED(hr)) ptrBrwHost.Release();
    }

    return ptrBrwHost;
}

HRESULT CBrowserHost::FinalConstruct()
{
    ATLVERIFY(InitSettings());

    HRESULT hr = CAxHostWindow::_CreatorClass::CreateInstance(
    static_cast<IBrowserHost*>(this), IID_PPV_ARGS(&m_ptrUnkInner));
    ATLASSERT(SUCCEEDED(hr));

    if(SUCCEEDED(hr))
        m_ptrOleInPlaceSite = m_ptrUnkInner;

    return hr;
}

// IBrowserHost::CreateHostWindow
STDMETHODIMP CBrowserHost::CreateHostWindow(
    /* [in] */ HWND hwndParent,
    /* [in] */ int nShowCmd,
    /* [in] */ bool bMainWindow)
{
    m_bMainWindow = bMainWindow;

    HRESULT hr = CreateWebBrowser(hwndParent);
    if(FAILED(hr))
        return hr;

    ShowWindow(nShowCmd);

    return hr;
}

HRESULT CBrowserHost::CreateWebBrowser(HWND hwndParent)
{
    // ...

    HRESULT hr = E_FAIL;
    CComQIPtr<IAxWinHostWindow> ptrWinHost = m_ptrUnkInner;

    if(ptrWinHost)
    {
        // m_webBrowser - child window
        hr = ptrWinHost->CreateControl(L"Shell.Explorer", m_webBrowser, NULL);
        ATLASSERT(SUCCEEDED(hr));
    }

    if(SUCCEEDED(hr))
        hr = HookUpWebBrowser(); // subscribe to browser's events, etc.

    return hr;
}

Note: The AtlAxWindowProc procedure calls OleInitialize on the WM_CREATE message, and OleUninitialize on the WM_NCDESTROY message. It is crucial to call OleInitialize instead of CoInitialize[Ex] in order to enable drag and drop, clipboard, and other OLE related operations. To ensure that OleInitialize is the first COM call made, we need to override the default implementation of the CAtlExeModuleT::InitializeCom method:

class CWebAppModule : public CAtlExeModuleT<CWebAppModule>
{
public:
    static HRESULT InitializeCom() throw()
    {
        // This is to make sure drag drop and clipboard works.
        return ::OleInitialize(NULL);
    }

    static void UninitializeCom() throw()
    {
        ::OleUninitialize();
    }
    
    // ...
};

Aggregating the CAxHostWindow Object

The single interface we're interested in is IOleInPlaceSite. This interface is implemented by the BrowserHost object. And the single method of this interface that requires special handling is IOleInPlaceSite::OnPosRectChange. Calls to all other methods are forwarded to the implementation provided by the CAxHostWindow object.

class ATL_NO_VTABLE CBrowserHost :
    public CComObjectRoot,
    public CComCoClass<CBrowserHost, &CLSID_NULL>,
    public IOleInPlaceSite,
    public IBrowserHost,
    ...
{
    DECLARE_NO_REGISTRY();

    // ...
    
BEGIN_COM_MAP(CBrowserHost)
    // ...
    COM_INTERFACE_ENTRY(IBrowserHost)
    COM_INTERFACE_ENTRY(IOleInPlaceSite)
    COM_INTERFACE_ENTRY_AGGREGATE_BLIND(m_ptrUnkInner)
END_COM_MAP()
};

The COM_INTERFACE_ENTRY_AGGREGATE_BLIND macro redirects all interface queries to the inner CAxHostWindow object.

CBrowserHost class' implementation of the IOleInPlaceSite::OnPosRectChange method leaves it empty, and always returns a E_NOTIMPL code. The actual resizing of the web browser window happens in response to the following web browser events:

  • DWebBrowserEvents2::WindowSetLeft
  • DWebBrowserEvents2::WindowSetTop
  • DWebBrowserEvents2::WindowSetWidth
  • DWebBrowserEvents2::WindowSetHeight

New Window Request

The traditional way to respond to new window requests from the WebBrowser control is to handle the DWebBrowserEvents2::NewWindow3 (or the NewWindow2) browser event:

void DWebBrowserEvents2::NewWindow2(
    IDispatch **ppDisp,
    VARIANT_BOOL *Cancel
);

void DWebBrowserEvents2::NewWindow3(
    IDispatch **ppDisp,
    VARIANT_BOOL *Cancel,
    DWORD dwFlags,
    BSTR bstrUrlContext,
    BSTR bstrUrl
);

An application has a chance to provide a new instance of the WebBrowser control for a new window or to cancel a new window altogether. Most of the hosting applications let the control to create a new window as it sees fit. WebApp is different in that respect. We need to implement the following logic:

  1. If a new window logically belongs to the currently opened web application, then create a new instance of the WebBrowser control in our process and use it for the new window. This way, the new browser window will share a security context with the main application window.
  2. If a new window doesn't belong to the web application, then open it in the default browser according to the user's settings: a new window or a new tab. The ShellExecute[Ex] function does exactly that.

Handling the first case is easy. We just create a new instance of our BrowserHost object, query the IDispatch interface from it, and pass it along to the WebBrowser control to use.

The second case is more tricky. If we call the ShellExecute[Ex] function with a specified URL, then we need to cancel the new window request for the hosted WebBrowser control, so the new window won't be opened twice. However, some web applications detect this and show a misleading message box that asks the user to disable the pop-up blocker software. The message is incorrect since the new window is opened in the default browser and not actually blocked. So, handling the new window request in the DWebBrowserEvents2::NewWindow3/2 event is too late.

INewWindowManager Interface

Luckily, the hosting application can instruct the WebBrowser control about how to create a new window by implementing the INewWindowManager interface. This interface has a single method: INewWindowManager::EvaluateNewWindow. So, the actual decision whether to open a new window as a part of our process is made in this method:

// INewWindowManager::EvaluateNewWindow
// Return values:
// S_OK - Allow display of the window.
// S_FALSE - Block display of the window.
// E_FAIL - WebBrowser control will use the default implementation.
STDMETHODIMP CBrowserHost::EvaluateNewWindow(
    /* [string][in] */ LPCWSTR pszUrl,
    /* [string][in] */ LPCWSTR pszName,
    /* [string][in] */ LPCWSTR pszUrlContext,
    /* [string][in] */ LPCWSTR pszFeatures,
    /* [in] */ BOOL fReplace,
    /* [in] */ DWORD dwFlags,
    /* [in] */ DWORD /*dwUserActionTime*/)
{
    HRESULT hr = E_FAIL; // let the WebBrowser control decide
    if(::OpenAsExternal(dwFlags))
    {
        hr = S_FALSE;
        const int res = (int)::ShellExecuteW(m_hWnd, L"open",
            pszUrl, NULL, NULL, SW_SHOWNORMAL);
        ATLASSERT(res > 32);
    }

    return hr;
}

// dwFlags - one or more of NWMF flags
bool OpenAsExternal(DWORD dwFlags)
{
    const DWORD dwOwnWndFlags =
        NWMF_SHOWHELP |
        NWMF_HTMLDIALOG |
        NWMF_SUGGESTWINDOW;

    return (dwFlags & dwOwnWndFlags) == 0;
}

The OpenAsExternal function answers to the question whether a new window should belong to the hosting application or to an external browser. By trial and error, I discovered that the above combination of NWMF flags produces the most adequate results.

The INewWindowManager interface must be exposed via the SID_SNewWindowManager service. It means that our hosting object must implement the IServiceProvider interface:

class ATL_NO_VTABLE CBrowserHost :
    public INewWindowManager,
    public IServiceProviderImpl<CBrowserHost>,
    ...
{
BEGIN_SERVICE_MAP(CBrowserHost)
    SERVICE_ENTRY(SID_SNewWindowManager)
END_SERVICE_MAP()

    // ...
};

The IServiceProviderImpl implementation and service map are provided by ATL.

Handling Special Keys and Shortcuts, the WM_FORWARDMSG Message

In order to enable keyboard navigation between controls on a web page, we need to handle special keys like Tab, PgUp, PgDown, Space etc. This is done by calling the IOleInPlaceActiveObject::TranslateAccelerator method of the WebBrowser control. If the control decides to translate a message, then it returns S_OK from this method, and S_FALSE, otherwise. We don't need to query the IOleInPlaceActiveObject interface from the WebBrowser control, the CAxHostWindow object already does the job. All we need to do is to forward the current message to the CAxHostWindow object by sending an ATL-specific message, WM_FORWARDMSG:

// Return values:
// S_OK - The message is translated by the control.
// S_FALSE - The message does not require translation.
STDMETHODIMP CBrowserHost::PreTranslateMessage(
    /* [in] */ const MSG& msg)
{
    if((msg.message < WM_KEYFIRST || msg.message > WM_KEYLAST) &&
       (msg.message < WM_MOUSEFIRST || msg.message > WM_MOUSELAST))
    {
        return S_FALSE;
    }

    if(!m_webBrowser.IsWindow())
        return S_FALSE;

    const LRESULT lTranslated = m_webBrowser.SendMessage(WM_FORWARDMSG, 0,
        reinterpret_cast<LPARAM>(&msg));

    return (lTranslated ? S_OK : S_FALSE);
}

// ATL's handling of WM_FORWARDMSG
// m_spUnknown - a pointer to hosted WebBrowser control.

LRESULT CAxHostWindow::OnForwardMsg(
    UINT /*uMsg*/,
    WPARAM /*wParam*/,
    LPARAM lParam,
    BOOL& /*bHandled*/)
{
    ATLASSERT(lParam != 0);
    LPMSG lpMsg = (LPMSG)lParam;
    CComQIPtr<IOleInPlaceActiveObject> spInPlaceActiveObject(m_spUnknown);
    if(spInPlaceActiveObject)
    {
        if(spInPlaceActiveObject->TranslateAccelerator(lpMsg) == S_OK)
            return 1;
    }
    return 0;
}

The CBrowserHost::PreTranslateMessage method is called within the message loop for the received window messages.

Lessons Learned

Looking back at the work done, the following conclusions can be drawn:

  • Aggregating the CAxHostWindow object wasn't probably the optimal decision. Separate creation of the hosting window and the control itself made the surrounding code quite complex. This complexity exists solely for the sake of reuse of the CAxWindow class, which otherwise would have been rewritten to better suit application needs.
  • The better solution could be: inheriting from the CAxHostWindow class in order to override the relevant COM interfaces implementation without involving aggregation.
  • The ATL framework, being a great tool for COM development, nevertheless, is extremely inflexible sometimes. The ActiveX hosting machinery is buried deep in the ATL internal code, scarcely documented, and requires considerable amount of work to customize.
  • The Common Controls library is buggy and under-documented, for the last 15 years.

That's all.

History

  • 12th October, 2009 - Bug fixed
    • Script error dialog box is not shown anymore
  • 13th October, 2009 - Bug fixed
    • IOleCommandTarget interface is implemented properly
      • Script errors are not shown
      • Progress notifications handled correctly, so page doesn't appear as infinitely loading anymore

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here