Introduction
Why pay money for a popup window blocker when it's so easy to roll your own? Popup Blocker is implemented in ATL as a browser helper object (BHO). A BHO is a DLL that will attach itself to every new instance of Internet Explorer. In the BHO you can intercept Internet Explorer events, and access the browser window and document object model (DOM). This gives you a great deal of flexibility in modifying Internet Explorer behavior.
There are several advantages to using a BHO over an application that continually scans open windows for keywords in the title. The BHO is event driven and does not run in a loop or use a timer, so it doesn't use any CPU cycles if nothing is happening. The BHO will prevent popups from opening and downloading their content, so you save bandwidth. The BHO can be written so as to eliminate the need for a keyword list or blacklist.
Also included, but not discussed in this article, is a Windows Installer setup program.
Creating the BHO
A minimal BHO is a COM server DLL that implements IObjectWithSite
. Create a new ATL Project and accept the default settings (attributed, DLL). Add an ATL Simple Object, give it a name and on the Options tab select Aggregation: No and Support: IObjectWithSite
.
The only method on IObjectWithSite
that must be implemented is SetSite()
. IE will call SetSite
with a pointer to IUnknown
which we can query for a pointer to IWebBrowser2
, which gives us the keys to the house.
STDMETHODIMP CPub::SetSite(IUnknown *pUnkSite)
{
if (!pUnkSite)
{
ATLTRACE(_T("SetSite(): pUnkSite is NULL\n"));
}
else
{
m_spWebBrowser2 = pUnkSite;
if (m_spWebBrowser2)
{
HRESULT hr = ManageBrowserConnection(ConnType_Advise);
if (FAILED(hr))
ATLTRACE(_T("Failure sinking events from IWebBrowser2\n"));
}
else
{
ATLTRACE(_T("QI for IWebBrowser2 failed\n"));
}
}
return S_OK;
}
Once we have the pointer to IWebBrowser2
we can hook up to the DWebBrowserEvents2
connection point in order to receive the NewWindow2
event. This event is sent every time a new window is about to open and allows you to cancel the operation -- exactly what we want for a popup window blocker.
HRESULT CPub::ManageBrowserConnection(ConnectType eConnectType)
{
if (eConnectType == ConnType_Unadvise && m_dwBrowserCookie == 0)
return S_OK;
ATLASSERT(m_spWebBrowser2);
if (!m_spWebBrowser2)
return S_OK;
CComQIPtr spCPC(m_spWebBrowser2);
HRESULT hr = E_FAIL;
if (spCPC)
{
CComPtr spCP;
hr = spCPC->FindConnectionPoint(DIID_DWebBrowserEvents2, &spCP);
if (SUCCEEDED(hr))
{
if (eConnectType == ConnType_Advise)
{
ATLASSERT(m_dwBrowserCookie == 0);
hr = spCP->Advise((IDispatch*)this, &m_dwBrowserCookie);
}
else
{
hr = spCP->Unadvise(m_dwBrowserCookie);
m_dwBrowserCookie = 0;
}
}
}
return hr;
}
To create the event handler we have to derive our class from IDispatchImpl
.
class ATL_NO_VTABLE CPub :
public IObjectWithSiteImpl<CPUB>,
public IDispatchImpl
Then add the Invoke method as follows:
STDMETHODIMP CPub::Invoke(DISPID dispidMember, REFIID riid, LCID lcid,
WORD wFlags, DISPPARAMS* pDispParams, VARIANT* pvarResult,
EXCEPINFO* pExcepInfo, UINT* puArgErr)
{
if (!pDispParams)
return E_INVALIDARG;
switch (dispidMember)
{
case DISPID_NEWWINDOW2:
pDispParams->rgvarg[0].pvarVal->vt = VT_BOOL;
pDispParams->rgvarg[0].pvarVal->boolVal = VARIANT_TRUE;
break;
default:
break;
}
return S_OK;
}
Once you get this much to compile, to make IE load the BHO you need to add the CLSID to the registry.
HKLM {
SOFTWARE {
Microsoft {
Windows {
CurrentVersion {
Explorer {
'Browser Helper Objects'
{
{C68AE9C0-0909-4DDC-B661-C1AFB9F5AE53}
}
}
}
}
}
}
If you've done everything right up to now, the BHO will load with every instance of IE and prevent all popup windows from opening. You can quickly test this by launching IE and selecting Open in New Window from the context menu. If the BHO is working properly the command should fail.
One problem you may notice is that even with no open browser windows the linker will sometimes fail because some process is using the BHO, forcing you to reboot the computer to release it. Needless to say, this can quickly become annoying. What is happening is that Windows Explorer also uses IE for its GUI, and so also loads the BHO. Since we want our BHO to load only when we launch IE, we need to make a change to DllMain
so that it doesn't get loaded by Windows Explorer. This should eliminate the linker problem.
BOOL WINAPI DllMain(DWORD dwReason, LPVOID lpReserved)
{
if (dwReason == DLL_PROCESS_ATTACH)
{
TCHAR pszLoader[MAX_PATH];
GetModuleFileName(NULL, pszLoader, MAX_PATH);
CString sLoader = pszLoader;
sLoader.MakeLower();
if (sLoader.Find(_T("explorer.exe")) >= 0)
return FALSE;
g_hinstPub = _AtlBaseModule.m_hInst;
}
return __super::DllMain(dwReason, lpReserved);
}
So now that we have our basic BHO, it would be nice to be able to exert some kind of control over it. For starters, we need to be able to enable or disable it. Also, it would be nice if Open in New Window from the context menu worked normally. There are probably a number of ways to do this, but I chose to add my own menu to the normal IE context menu. To access the IE context menu we need to derive from the IDocHostUIHandler
interface and implement the ShowContextMenu
method.
class ATL_NO_VTABLE CPub :
public IObjectWithSiteImpl<CPUB>,
public IDispatchImpl,
public IDocHostUIHandler
Add the ShowContextMenu
method:
HRESULT CPub::ShowContextMenu(DWORD dwID,
POINT *ppt,
IUnknown *pcmdTarget,
IDispatch *pdispObject)
{
return S_OK;
}
In order to get IE to call our ShowContextMenu
we use the ICustomDoc::SetUIHandler
method. Typically, this is done when we get the NavigateComplete2 event because at that point the document has been created and we can access the browsers IHTMLDocument2
interface. However, because we need to get the default handlers for IDocHostUIHandler
and IOleCommandTarget
(more on that later), instead we will do it in DocumentComplete
after the entire document has been loaded. So we add a case to our Invoke method:
case DISPID_NAVIGATECOMPLETE2:
ATLTRACE(_T("(%ld) DISPID_NAVIGATECOMPLETE2\n"),
::GetCurrentThreadId());
{
m_bBlockNewWindow = TRUE;
if (!m_pWBDisp)
{
m_pWBDisp = pDispParams->rgvarg[1].pdispVal;
}
}
break;
case DISPID_DOCUMENTCOMPLETE:
ATLTRACE(_T("(%ld) DISPID_DOCUMENTCOMPLETE\n"),
::GetCurrentThreadId());
if (m_pWBDisp &&
m_pWBDisp == pDispParams->rgvarg[1].pdispVal)
{
ATLTRACE(_T("(%ld) DISPID_DOCUMENTCOMPLETE (final)\n"),
::GetCurrentThreadId());
m_pWBDisp = NULL;
CComPtr spDisp;
HRESULT hr = m_spWebBrowser2->get_Document(&spDisp);
if (SUCCEEDED(hr) && spDisp)
{
CComQIPtr spHTML(spDisp);
if (spHTML)
{
CComQIPtr spOleObject(spDisp);
if (spOleObject)
{
CComPtr spClientSite;
hr = spOleObject->GetClientSite(&spClientSite);
if (SUCCEEDED(hr) && spClientSite)
{
m_spDefaultDocHostUIHandler = spClientSite;
m_spDefaultOleCommandTarget = spClientSite;
}
}
CComQIPtr spCustomDoc(spDisp);
if (spCustomDoc)
spCustomDoc->SetUIHandler(this);
}
}
}
break;
If you compile and run now, you should see that the IE context menu is disabled since we are always returning S_OK from the ShowContextMenu
method. In the MSDN documentation there is an excellent article titled "WebBrowser Customization" that explains exactly, with sample code, how to add code to ShowContextMenu
so as to replicate the original IE context menu, so I won't bother to talk about all that here. What the article did not explain was how to add your own menu to that context menu. Create your menu resource and then add this menu to the top of the context menu in the normal fashion. Nothing tricky here:
g_hPubMenu = LoadMenu(g_hinstPub, MAKEINTRESOURCE(IDR_PUBMENU));
if (g_hPubMenu)
{
::InsertMenu(hSubMenu, 0, MF_POPUP | MF_BYPOSITION,
(UINT_PTR) g_hPubMenu, _T("Popup Blocker"));
::InsertMenu(hSubMenu, 1, MF_BYPOSITION | MF_SEPARATOR, NULL, NULL);
}
The tricky part comes when you run the BHO and notice that the all the items on the context menu work as you expect EXCEPT for those items on your menu, which are disabled. This is because IE inconveniently disregards menu items it does not recognize and will not enable your menu items even if you tell it to do so with the EnableMenuItem
command. To fix this, we need to temporarily add our own window procedure just before the call to TrackPopupMenu
so that we can handle messages for our menu, then we restore the original window procedure immediately after TrackPopupMenu
returns.
LRESULT CALLBACK CtxMenuWndProc(HWND hwnd, UINT uMsg, WPARAM wParam,
LPARAM lParam)
{
if (uMsg == WM_INITMENUPOPUP)
{
if (wParam == (WPARAM) g_hPubMenu)
{
::CheckMenuItem(g_hPubMenu,
ID_ENABLEPOPUPBLOCKER, MF_BYCOMMAND | (g_bEnabled ?
MF_CHECKED : MF_UNCHECKED));
::CheckMenuItem(g_hPubMenu, ID_PLAYSOUND,
MF_BYCOMMAND | (g_bPlaySound ? MF_CHECKED : MF_UNCHECKED));
return 0;
}
}
return CallWindowProc(g_lpPrevWndProc, hwnd, uMsg, wParam, lParam);
}
If a message is not for our menu, we just pass it on to the original window procedure. Now that the menu is working, you can add switches and dialogs to control the behavior of the BHO. In the code included with this article I added a checked menu item to enable/disable the BHO. I also intercepted the normal Open in New Window command and set a flag temporarily disabling the BHO in order to restore normal behavior.
So at this point we have a popup blocker that works pretty well, but there are still a couple of glitches. The first one that you will notice is that certain links seem to be disabled, that is nothing happens when they are clicked. These are links that pop up another window, usually through a bit of script. The BHO happily suppresses these windows with no indication to the user. To solve this problem, I added a way to temporarily override the BHO by holding down the CTRL key. You have to be careful here to not disable the SHIFT + Click shortcut used to open a new window (thanks to Matt Newman for pointing that one out!).
BOOL CPub::IsHotkeyDown()
{
SHORT nState = GetAsyncKeyState(VK_CONTROL);
BOOL bDown = (nState & 0x8000);
if (!bDown)
{
nState = GetAsyncKeyState(VK_SHIFT);
bDown = (nState & 0x8000);
}
return bDown;
}
But how is the user supposed to know when to press the hot key? In my code I chose to play a sound every time a popup window is suppressed. That way the user has an indication that the BHO is in the act when a link appears to be broken. Then all the user needs to do is hold down the CTRL key while clicking the link, and the link will work normally.
Now our popup blocker is working really well. It blocks all popup windows and we can shut it off if we need to. However, there are still a couple of problems to solve and you probably won't notice them right away. For one, when browsing the web you will start to see these IE Script Error dialogs popping up:
This is even more annoying than the normal popup ads. They occur because some scripts expect to have the popup window available and generate an error when it is not. Because not all script writers add error handlers to their pages, IE acts as the default error handler and poups up its own cryptic dialog box. Another problem is that some options on the Save As dialog under "Save as type" have disappeared, and there are various other UI problems when hosting IE -- like scroll bars going away when viewing help inside Visual Studio.
To solve script error problem, I implemented IOleCommandTarget
and added the methods for QueryStatus
and Exec
. If I get an OLECMDID_SHOWSCRIPTERROR
in Exec
, I set pvaOut
to VARIANT_TRUE
to indicate that I wish to continue running scripts and return S_OK
instead of the default to suppress the script error dialog.
class ATL_NO_VTABLE CPub :
public IObjectWithSiteImpl<CPUB>,
public IDispatchImpl,
public IDocHostUIHandler,
public IOleCommandTarget
STDMETHOD(QueryStatus)(
const GUID *pguidCmdGroup,
ULONG cCmds,
OLECMD *prgCmds,
OLECMDTEXT *pCmdText)
{
return m_spDefaultOleCommandTarget->QueryStatus(pguidCmdGroup, cCmds,
prgCmds, pCmdText);
}
STDMETHOD(Exec)(
const GUID *pguidCmdGroup,
DWORD nCmdID,
DWORD nCmdExecOpt,
VARIANTARG *pvaIn,
VARIANTARG *pvaOut)
{
if (nCmdID == OLECMDID_SHOWSCRIPTERROR)
{
(*pvaOut).vt = VT_BOOL;
(*pvaOut).boolVal = VARIANT_TRUE;
return S_OK;
}
return m_spDefaultOleCommandTarget->Exec(pguidCmdGroup, nCmdID,
nCmdExecOpt, pvaIn, pvaOut);
}
With our IOleCommandTarget
implementation in place all unhandled script errors are gobbled up and the default IE Script Error dialogs never show up.
I should mention that in my first implementation I was intercepting the HTMLWindowEvents2::onerror
event which gets fired whenever an unhandled script error occurs. It is indeed possible to do it that way, but you have to be careful to attach the handler to the top-level window. If you attach to a child frame, then events in a sibling frame will not be caught (as pointed out by ferdo. Thanks!). If you attach to the top-level window, then unhandled events will eventually bubble up to your handler. In digging into that problem, I decided to scrap that method in favor of the IOleCommandTarget
implementation just because I thought it was cleaner and easier to understand.
The Save As and UI problems are solved by simply calling the default handler from within each IDocHostUIHandler
method . Using the pointer to the default handler we got during DISPID_DOCUMENTCOMPLETE
, we call the default if we are not handling the method ourselves.
STDMETHOD(GetHostInfo)(DOCHOSTUIINFO FAR *pInfo)
{
if (m_spDefaultDocHostUIHandler)
return m_spDefaultDocHostUIHandler->GetHostInfo(pInfo);
return S_OK;
}
It's not clear why these UI problems are not mentioned in MSDN documentation on BHOs (if they are, I missed them), but they definately should be.
<shameless_plug>The latest version can be installed from the web at Osborn Technologies.</shameless_plug>
Improvements
There is still room for a number of improvements:
- Make the sound and hotkeys user selectable.
- Add an optional visual indicator that a popup window has been blocked.
- Add a whitelist that will allow popups for an entire website or domain.
Revision History
- April 26,2003: Fixed SHIFT+Click and untrapped script error problems.