65.9K
CodeProject 正在变化。 阅读更多。
Home

弹出窗口阻止程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (49投票s)

2003年4月24日

8分钟阅读

viewsIcon

896353

downloadIcon

9721

弹出窗口拦截器是一个浏览器帮助程序对象(BHO),它可以消除所有弹出和弹底式窗口,包括那些由脚本自动打开的窗口。

Sample Image - PopupBlocker.jpg

引言

既然自己动手编写一个弹出窗口拦截器如此简单,为何还要花钱购买呢?弹出窗口拦截器是作为一个浏览器帮助程序对象(BHO)在 ATL 中实现的。BHO 是一个 DLL,它会附加到 Internet Explorer 的每个新实例上。在 BHO 中,你可以拦截 Internet Explorer 的事件,并访问浏览器窗口和文档对象模型(DOM)。这为你修改 Internet Explorer 的行为提供了极大的灵活性。

与持续扫描打开窗口标题中关键字的应用程序相比,使用 BHO 有几个优势。BHO 是事件驱动的,不以循环或计时器的方式运行,因此如果没有事件发生,它不会占用任何 CPU 周期。BHO 会阻止弹出窗口打开并下载其内容,从而节省带宽。BHO 的编写方式可以使其无需关键字列表或黑名单。

项目中还包含一个 Windows Installer 安装程序,但本文不作讨论。

创建 BHO

一个最基本的 BHO 是一个实现了 IObjectWithSite 接口的 COM 服务器 DLL。创建一个新的 ATL 项目并接受默认设置(特性化,DLL)。添加一个 ATL 简单对象,为其命名,并在“选项”选项卡中选择“聚合:否”和“支持:IObjectWithSite”。

IObjectWithSite 接口上唯一必须实现的方法是 SetSite()。IE 会调用 SetSite 并传入一个指向 IUnknown 的指针,我们可以通过它查询到指向 IWebBrowser2 的指针,这就给了我们控制浏览器的一切权限。

//
// IOleObjectWithSite Methods
//
STDMETHODIMP CPub::SetSite(IUnknown *pUnkSite)
{
    if (!pUnkSite)
    {
        ATLTRACE(_T("SetSite(): pUnkSite is NULL\n"));
    }
    else
    {
        // Query pUnkSite for the IWebBrowser2 interface.
        m_spWebBrowser2 = pUnkSite;
        if (m_spWebBrowser2)
        {
            // Connect to the browser in order to handle events.
            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;
}
        

一旦我们获得了指向 IWebBrowser2 的指针,我们就可以挂接到 DWebBrowserEvents2 连接点,以便接收 NewWindow2 事件。每当一个新窗口即将打开时,这个事件就会被发送,并允许你取消该操作——这正是我们想要的弹出窗口拦截器功能。

//
// Funnel web browser events through this class
//
HRESULT CPub::ManageBrowserConnection(ConnectType eConnectType)
{
    if (eConnectType == ConnType_Unadvise && m_dwBrowserCookie == 0)
        return S_OK;    // not advised, nothing to do

    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;
}

为了创建事件处理器,我们必须让我们的类派生自IDispatchImpl.

class ATL_NO_VTABLE CPub : 
    public IObjectWithSiteImpl<CPUB>,
    public IDispatchImpl
        

然后按如下方式添加 Invoke 方法

//
// IDispatch Methods
//
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)
    {
        //
        // The parameters for this DISPID are as follows:
        // [0]: Cancel flag  - VT_BYREF|VT_BOOL
        // [1]: IDispatch* - Pointer to an IDispatch interface. 
        //
        // If you cancel here, ALL popups will be blocked.
        //
    case DISPID_NEWWINDOW2:
        // Set the cancel flag to block popups
        pDispParams->rgvarg[0].pvarVal->vt = VT_BOOL;
        pDispParams->rgvarg[0].pvarVal->boolVal = VARIANT_TRUE;
        break;
    default:
        break;
   }
   
   return S_OK;
}

一旦你让这些代码编译通过,为了让 IE 加载 BHO,你需要将 CLSID 添加到注册表中。

HKLM {
    SOFTWARE {
        Microsoft {    
            Windows {
                CurrentVersion {
                    Explorer {
                        'Browser Helper Objects'
                        {
                            {C68AE9C0-0909-4DDC-B661-C1AFB9F5AE53}
                        }
                    }
                }
            }
        }
}
        

如果到目前为止你所做的一切都正确,BHO 将会随 IE 的每个实例加载,并阻止所有弹出窗口的打开。你可以通过启动 IE 并从上下文菜单中选择“在新窗口中打开”来快速测试。如果 BHO 工作正常,该命令应该会失败。

你可能会注意到的一个问题是,即使没有打开任何浏览器窗口,链接器有时也会因为某个进程正在使用 BHO 而失败,迫使你重启计算机来释放它。不用说,这很快就会变得烦人。发生这种情况是因为 Windows 资源管理器也使用 IE 作为其 GUI,因此也会加载 BHO。由于我们希望我们的 BHO 仅在启动 IE 时加载,我们需要对 DllMain 进行更改,以使其不被 Windows 资源管理器加载。这应该能解决链接器的问题。

BOOL WINAPI DllMain(DWORD dwReason, LPVOID lpReserved) 
{
    if (dwReason == DLL_PROCESS_ATTACH)
    {
        // Don't attach to Windows Explorer
        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);
}
        

现在我们有了基本的 BHO,如果能对其进行某种控制就更好了。首先,我们需要能够启用或禁用它。此外,如果上下文菜单中的“在新窗口中打开”能正常工作就好了。实现这一点可能有很多种方法,但我选择在常规的 IE 上下文菜单中添加我自己的菜单。要访问 IE 上下文菜单,我们需要从IDocHostUIHandler 接口派生并实现 ShowContextMenu 方法。

class ATL_NO_VTABLE CPub : 
    public IObjectWithSiteImpl<CPUB>,
    public IDispatchImpl,
    public IDocHostUIHandler
        

添加 ShowContextMenu 方法

HRESULT CPub::ShowContextMenu(DWORD dwID,
                              POINT *ppt,
                              IUnknown *pcmdTarget,
                              IDispatch *pdispObject) 
{
    // Return S_OK to tell MSHTML not to display its own menu
    // Return S_FALSE displays default MSHTML menu
    return S_OK;
}
        

为了让 IE 调用我们的 ShowContextMenu,我们使用ICustomDoc::SetUIHandler 方法。通常,这是在收到 NavigateComplete2 事件时完成的,因为那时文档已经创建,我们可以访问浏览器的 IHTMLDocument2 接口。然而,因为我们需要获取 IDocHostUIHandlerIOleCommandTarget 的默认处理器(稍后会详细介绍),所以我们将在整个文档加载完成后的 DocumentComplete 事件中进行。因此,我们为 Invoke 方法添加一个 case 分支

//
	// The parameters for this DISPID:
	// [0]: URL navigated to - VT_BYREF|VT_VARIANT
	// [1]: An object that evaluates to the top-level or frame
	//      WebBrowser object corresponding to the event. 
	//
	// Fires after a navigation to a link is completed on either 
	// a window or frameSet element.
	//
case DISPID_NAVIGATECOMPLETE2:
	ATLTRACE(_T("(%ld) DISPID_NAVIGATECOMPLETE2\n"),
		::GetCurrentThreadId());
	{
		// Any new windows that might pop up after this
		// (due to script) should be blocked.
		m_bBlockNewWindow = TRUE;	// Reset

		if (!m_pWBDisp)
		{
			// This is the IDispatch* of the top-level browser
			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)
	{
		// If the LPDISPATCH are same, that means
		// it is the final DocumentComplete. Reset m_pWBDisp.
		ATLTRACE(_T("(%ld) DISPID_DOCUMENTCOMPLETE (final)\n"),
			::GetCurrentThreadId());
		m_pWBDisp = NULL;

		CComPtr spDisp;
		HRESULT hr = m_spWebBrowser2->get_Document(&spDisp);
		if (SUCCEEDED(hr) && spDisp)
		{
			// If this is not an HTML document (e.g., it's a
			// Word doc or a PDF), don't sink.
			CComQIPtr spHTML(spDisp);
			if (spHTML)
			{
			  // Get pointers to default interfaces
			  CComQIPtr spOleObject(spDisp);
			  if (spOleObject)
			  {
			    CComPtr spClientSite;
			    hr = spOleObject->GetClientSite(&spClientSite);
			    if (SUCCEEDED(hr) && spClientSite)
			    {
			      m_spDefaultDocHostUIHandler = spClientSite;
			      m_spDefaultOleCommandTarget = spClientSite;
			    }
			  }

			  // Set this class to be the IDocHostUIHandler
			  CComQIPtr spCustomDoc(spDisp);
			  if (spCustomDoc)
			    spCustomDoc->SetUIHandler(this);
			}
		} 
	}
	break;

如果你现在编译并运行,你会发现 IE 的上下文菜单被禁用了,因为我们的 ShowContextMenu 方法总是返回 S_OK。在 MSDN 文档中,有一篇名为“WebBrowser Customization”的优秀文章,它详细解释了(并附有示例代码)如何向 ShowContextMenu 添加代码以复制原始的 IE 上下文菜单,所以我不会在这里赘述。但该文章没有解释如何将你自己的菜单添加到那个上下文菜单中。创建你的菜单资源,然后以常规方式将此菜单添加到上下文菜单的顶部。这里没有什么技巧

// Insert our menu at the top of the context menu
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);
}
        

棘手的部分在于,当你运行 BHO 时,你会注意到上下文菜单上的所有项目都如预期工作,除了你菜单上的那些项目,它们是禁用的。这是因为 IE 会不方便地忽略它不认识的菜单项,并且即使你使用 EnableMenuItem 命令告诉它启用你的菜单项,它也不会启用。为了解决这个问题,我们需要在调用 TrackPopupMenu 之前临时添加我们自己的窗口过程,以便处理我们菜单的消息,然后在 TrackPopupMenu 返回后立即恢复原始的窗口过程。

LRESULT CALLBACK CtxMenuWndProc(HWND hwnd, UINT uMsg, WPARAM wParam,
	LPARAM lParam)
{
    if (uMsg == WM_INITMENUPOPUP)
    {
        if (wParam == (WPARAM) g_hPubMenu)
        {
            // This is our menu
            ::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);
}
            

如果消息不是针对我们菜单的,我们只需将其传递给原始的窗口过程。现在菜单可以工作了,你可以添加开关和对话框来控制 BHO 的行为。在本文附带的代码中,我添加了一个勾选菜单项来启用/禁用 BHO。我还拦截了常规的“在新窗口中打开”命令,并设置一个标志临时禁用 BHO,以恢复其正常行为。

到此为止,我们有了一个运行得相当不错的弹出窗口拦截器,但仍有几个小问题。你首先会注意到的是,某些链接似乎被禁用了,也就是说点击它们没有任何反应。这些链接通常会通过一小段脚本弹出一个新窗口。BHO 会愉快地拦截这些窗口,但不会给用户任何提示。为了解决这个问题,我增加了一种通过按住 CTRL 键来临时覆盖 BHO 的方法。这里你必须小心,不要禁用用于打开新窗口的 SHIFT + Click 快捷键(感谢 Matt Newman 指出这一点!)。

BOOL CPub::IsHotkeyDown()
{
	SHORT nState = GetAsyncKeyState(VK_CONTROL);
	BOOL bDown = (nState & 0x8000);
	if (!bDown)
	{
		// Allow shift+click to open a new window
		nState = GetAsyncKeyState(VK_SHIFT);
		bDown = (nState & 0x8000);
	}
	return bDown;
}
            

但是用户怎么知道什么时候该按热键呢?在我的代码中,我选择在每次拦截弹出窗口时播放一个声音。这样,当一个链接看起来像坏掉时,用户就会得到一个 BHO 正在工作的提示。然后用户只需在点击链接时按住 CTRL 键,链接就能正常工作了。

现在我们的弹出窗口拦截器工作得非常好了。它能拦截所有弹出窗口,并且在需要时我们可以关闭它。然而,还有几个问题需要解决,你可能不会马上注意到它们。其一,在浏览网页时,你会开始看到这些 IE 脚本错误对话框弹出

IE Script Error dialog

这甚至比普通的弹出广告更烦人。它们之所以出现,是因为一些脚本期望弹出窗口可用,而在窗口不可用时产生错误。因为并非所有脚本编写者都在其页面中添加了错误处理程序,IE 就充当了默认的错误处理程序,并弹出它自己的、令人费解的对话框。另一个问题是,“另存为”对话框中“保存类型”下的一些选项消失了,并且在托管 IE 时还存在各种其他 UI 问题——比如在 Visual Studio 内部查看帮助时滚动条消失了。

为了解决脚本错误问题,我实现了 IOleCommandTarget 接口并添加了 QueryStatusExec 方法。如果在 Exec 中收到了一个 OLECMDID_SHOWSCRIPTERROR,我会将 pvaOut 设置为 VARIANT_TRUE 来表示我希望继续运行脚本,并返回 S_OK 而不是默认值,以抑制脚本错误对话框的出现。

class ATL_NO_VTABLE CPub : 
	public IObjectWithSiteImpl<CPUB>,
	public IDispatchImpl,
	public IDocHostUIHandler,
	public IOleCommandTarget
			
STDMETHOD(QueryStatus)(
	/*[in]*/ const GUID *pguidCmdGroup, 
	/*[in]*/ ULONG cCmds,
	/*[in,out][size_is(cCmds)]*/ OLECMD *prgCmds,
	/*[in,out]*/ OLECMDTEXT *pCmdText)
{
	return m_spDefaultOleCommandTarget->QueryStatus(pguidCmdGroup, cCmds,
		prgCmds, pCmdText);
}
	
STDMETHOD(Exec)(
	/*[in]*/ const GUID *pguidCmdGroup,
	/*[in]*/ DWORD nCmdID,
	/*[in]*/ DWORD nCmdExecOpt,
	/*[in]*/ VARIANTARG *pvaIn,
	/*[in,out]*/ VARIANTARG *pvaOut)
{
	if (nCmdID == OLECMDID_SHOWSCRIPTERROR)
	{
		// Don't show the error dialog, but
		// continue running scripts on the page.
		(*pvaOut).vt = VT_BOOL;
		(*pvaOut).boolVal = VARIANT_TRUE;
		return S_OK;
	}
	return m_spDefaultOleCommandTarget->Exec(pguidCmdGroup, nCmdID,
		nCmdExecOpt, pvaIn, pvaOut);
}
		

有了我们的 IOleCommandTarget 实现,所有未处理的脚本错误都被吞掉了,默认的 IE 脚本错误对话框再也不会出现。

我应该提一下,在我的第一个实现中,我拦截了 HTMLWindowEvents2::onerror 事件,该事件在发生未处理的脚本错误时触发。确实可以用那种方式实现,但你必须小心地将处理器附加到顶层窗口。如果你附加到子框架,那么同级框架中的事件将不会被捕获(正如 ferdo 指出的那样。谢谢!)。如果你附加到顶层窗口,那么未处理的事件最终会冒泡到你的处理器。在深入研究那个问题时,我决定放弃那种方法,转而使用 IOleCommandTarget 的实现,仅仅因为我认为它更清晰、更容易理解。

“另存为”和 UI 问题可以通过在每个 IDocHostUIHandler 方法内部简单地调用默认处理器来解决。使用我们在 DISPID_DOCUMENTCOMPLETE 期间获得的指向默认处理器的指针,如果我们自己不处理该方法,就调用默认的处理器。

STDMETHOD(GetHostInfo)(DOCHOSTUIINFO FAR *pInfo)
{
	if (m_spDefaultDocHostUIHandler)
		return m_spDefaultDocHostUIHandler->GetHostInfo(pInfo);
	return S_OK;
}
		

目前尚不清楚为什么这些 UI 问题在 MSDN 关于 BHO 的文档中没有被提及(如果提到了,那我错过了),但它们绝对应该被提及。

<shameless_plug>最新版本可以从网站 Osborn Technologies 安装。</shameless_plug>

改进

还有许多可以改进的地方

  • 让声音和热键可以由用户选择。
  • 添加一个可选的视觉指示器,用于提示弹出窗口已被拦截。
  • 添加一个白名单,允许整个网站或域名的弹出窗口。

修订历史

  • 2003年4月26日:修复了 SHIFT+Click 和未捕获的脚本错误问题。
© . All rights reserved.