第一节 一步步教你如何编写Shell扩展
所谓的Shell扩展就是能够添加某种功能到Windows ShellCOM对象。
Windows里有着各种各样的扩展,但关于Shell扩展的原理以及如何编写Shell扩展的文档却很少。
如果你想深入地了解Shell各方面的细节,我特别推荐Dino Esposito的著作《Visual C++ Windows Shell Programming》。
但对于那些没有这本书的,或只对Shell扩展本身感兴趣的朋友,我写了这个编程指南希望能够帮助你理解怎样编写Shell扩展。
该指南假设你理解COMATL的基本原理及应用。

第一节对Shell扩展进行了概括性的介绍, 并给出了一个上下文菜单的扩展以引起你对以后各章的兴趣.

Shell扩展到底是什么玩意呢?
"Shell扩展"从字面上分两个部分,ShellExtension
ShellWindows Explorer, Extension则指由你编写的当某一预先约定好的事件(如在以.doc为后缀的文件图标上单击右键)发生时由Explorer调用执行的代码。
因此一个Shell扩展就是一个为Explorer添加功能的COM对象。

Shell扩展是个进程内服务器(运行在Explorer进程内),它实现了一些接口来处理与 Explorer 的通信。
ATL在我看来是设计Shell扩展最简单最快捷的方法, 如果没有它,你就不得不一遍又一遍地编写繁琐的 QueryInterface() AddRef()代码.
另外,在Windows NT 2000上调试Shell扩展相对比较容易一些,这我以后会讲到的。

Shell扩展有很多种类型,每种类型都在各自不同的事件发生时被调用运行,但也有一些扩展的类型和调用情形是非常相似的。
类型
何时被调用
应该作些什么
Context menu
扩展处理器
用户右键单击文件或文件夹对象时,
或在一个文件夹窗口中的背景处单击右键时(要求shell版本为4.71+
添加菜单项到上下文菜单中
Property sheet
扩展处理器
要显示一个文件对象的属性框时
添加定制属性页到属性表中
Drag and drop
扩展处理器
用户用右键拖放文件对象到文件夹窗口或桌面时
添加菜单项到上下文菜单中
Drop 扩展处理器
用户拖动Shell对象并将它放到一个文件对象上时
任何想要的操作
QueryInfo扩展处理器 (需要shell版本 4.71+)
用户将鼠标盘旋于文件或其他Shell对象的图标上时
返回一个浏览器用于显示在提示框中的字符串
现在你可能想知道Shell扩展到底是什么样的. 如果你安装了 WinZip (有谁没装的吗?, 它就包含了多种的Shell扩展,其中也就有上下文菜单扩展.
下图是WinZip 8 为压缩文件对象添加的定制菜单项:


WinZip 编写了添加菜单项的代码, 提供了浏览器状态栏上的菜单项帮助提示, 并在用户选择一个菜单命令时执行相应的操作。

WinZip 还包括一个拖放目标扩展处理器. 该类型与上下文菜单十分类似, 但它是在用户用右键拖放文件时被触发的.
下图是 WinZip 定制的拖放菜单:


Shell扩展的类型很多,而且微软也正不断地在每一新版本的Windows中加入更多的扩展类型.
现在让我们把注意力放在上下文菜单上, 因为它们易于编写,效果也很明显(这能马上满足你).

在我们编写代码之前, 先说一下一些简化编码及调试工作的技巧.
shell扩展被 Explorer调用后, 它会在内存中呆上一段时间, 这会使你无法重新编译并生成Shell扩展DLL文件.

要让 Explorer 更迅速地卸载Shell扩展执行文件,需要创建如下注册表项:
HKLM\Software\Microsoft\Windows\CurrentVersion\Explorer\AlwaysUnloadDLL
并将其值设为 "1". 对于Win9x, 这是你能做的最好的方法。

而在Win NT/2000, 你可以到如下键:
HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer
并创建一个名为DesktopProcessDWORD 1. 这会使桌面和任务栏运行在同一个进程中, 而其他每一个 Explorer 窗口都运行在它自己的每一个进程内. 也就是说,你可以在单个的Explorer 窗口内进行调试, 而后只要你关闭该窗口,你的DLL就会被马上卸载, 这就避免了因为DLL正被Windows使用而无法替换更新. 而如果不幸出现这种情况,你就不得不注销登录后再重新登录进Windows从而强制卸载使用中的Shell扩展DLL.

我将在稍后解释如何在Win9x中进行调试的细节.

开始编写上下文菜单它该做些什么?
开头先让我们做简单一些, 只弹出一个对话框以表明当前的扩展能够正常地工作.
我们把扩展关联到 .TXT 文件, 因此当用户右键单击文本文件对象时扩展就会被调用.

使用 AppWizard 开始
好吧, 让我们开始吧! 什么? 我还没告诉你怎样使用那些神秘的 shell 扩展接口?
别着急, 我会边进行边解释的。
我觉得先解释一下一个概念再紧接着说明示例代码,对理解例子程序会更简单一些. 当然我也可以把所有的东西都先解释完,然后再解释代码, 但我觉得这样做不能吸引人的注意力。不管怎么样, VC开火,开始!

运行AppWizard,生成一个名为SimpleExt ATL COM 工程. 保留所有默认的设置选项,点击”完成”.
现在我们已经有了一个空的 ATL工程,它可以编译并生成一个 DLL, 但我们还需要添加Shell扩展的 COM 对象.
ClassView , 右击 SimpleExt classes 条目, 选择 New ATL Object.
ATL Object Wizard, 第一页默认已经选择了 Simple Object , 所以单击 Next 即可.
在第二页中, Short Name 文本框里输入 SimpleShlExt ,点击 OK. (其余的文本框会自动填充完.)
这样就创建了一个名为 CSimpleShlExt 的类,其包含了实现COM对象最基本的代码. 我们将在这个类中加入我们自己的代码.

初始化接口
当我们的shell扩展被加载时, Explorer 将调用我们所实现的COM对象的 QueryInterface() 函数以取得一个 IShellExtInit 接口指针.
该接口仅有一个方法 Initialize(), 其函数原型为:
HRESULT IShellExtInit::Initialize ( LPCITEMIDLIST pidlFolder, LPDATAOBJECT pDataObj, HKEY hProgID );

Explorer 使用该方法传递给我们各种各样的信息.
PidlFolder是用户所选择操作的文件所在的文件夹的 PIDL 变量. (一个 PIDL [指向ID 列表的指针] 是一个数据结构,它唯一地标识了在Shell命名空间的任何对象, 一个Shell命名空间中的对象可以是也可以不是真实的文件系统中的对象.)
pDataObj 是一个 IDataObject 接口指针,通过它我们可以获取用户所选择操作的文件名。
hProgID 是一个HKEY 注册表键变量,可以用它获取我们的DLL的注册数据.
在这个简单的扩展例子中, 我们将只使用到 pDataObj 参数.
要添加这个接口进 COM 对象, 先打开SimpleShlExt.h 文件, 然后加入下列标红的代码:
#include "shlobj.h"
#include "comdef.h"
class ATL_NO_VTABLE CSimpleShlExt :
public CComObjectRootEx,
public CComCoClass,
public IDispatchImpl,
public IShellExtInit

BEGIN_COM_MAP(CSimpleShlExt)
COM_INTERFACE_ENTRY(ISimpleShlExt)
COM_INTERFACE_ENTRY(IDispatch)
COM_INTERFACE_ENTRY(IShellExtInit)
END_COM_MAP()
COM_MAPATL实现 QueryInterface()机制的宏,它包含的列表告诉ATL其它外部程序用QueryInterface()能从我们的 COM对象获取哪些接口.
接着,在类声明里, 加入Initialize()的函数原型.
另外我们需要一个变量来保存文件名:
protected:
TCHAR m_szFile [MAX_PATH];
public:
// IShellExtInit
STDMETHOD(Initialize)(LPCITEMIDLIST, LPDATAOBJECT, HKEY);
然后, SimpleShlExt.cpp 文件中, 加入该函数方法的实现定义:
HRESULT CSimpleShlExt::Initialize ( LPCITEMIDLIST pidlFolder, LPDATAOBJECT pDataObj, HKEY hProgID )
我们要做的是取得被右击选择的文件名,再把该文件名显示在弹出消息框中。
可能会有多个文件同时被选择右击, 你可以用pDataObj 接口指针获取所有的文件名, 但现在为简单起见, 我们只获取第一个文件名.

文件名的存放格式与你拖放文件到带WS_EX_ACCEPTFILES风格的窗口时使用的文件名格式是一样的。
这就是说我们可以使用同样的API来获取文件名: DragQueryFile().
首先我们先获取包含在IdataObject中的数据句柄:
{
FORMATETC fmt = { CF_HDROP, NULL, DVASPECT_CONTENT, -1, TYMED_HGLOBAL };
STGMEDIUM stg = { TYMED_HGLOBAL };
HDROP hDrop;

// 在数据对象内查 CF_HDROP 型数据.
if ( FAILED( pDataObj->GetData ( &fmt, &stg )))
{
// Nope! Return an "invalid argument" error back to Explorer.
return E_INVALIDARG;
}

// 获得指向实际数据的指针
hDrop = (HDROP) GlobalLock ( stg.hGlobal );

// 检查非NULL.
if ( NULL == hDrop )
{
return E_INVALIDARG;
}
请注意错误检查,特别是指针的检查。
由于我们的扩展运行在 Explorer 进程内, 要是我们的代码崩溃了, Explorer也会随之崩溃. Win 9x, 这样的一个崩溃可能导致需要重启系统.

所以, 现在我们有了一个 HDROP 句柄, 我们就可以获取我们需要的文件名了:
// 有效性检查保证最少有一个文件名.
UINT uNumFiles = DragQueryFile ( hDrop, 0xFFFFFFFF, NULL, 0 );
if ( 0 == uNumFiles )
{
GlobalUnlock ( stg.hGlobal );
ReleaseStgMedium ( &stg );
return E_INVALIDARG;
}
HRESULT hr = S_OK;

// 取得第一个文件名并把它保存在类成员m_szFile .
if ( 0 == DragQueryFile ( hDrop, 0, m_szFile, MAX_PATH ))
{
hr = E_INVALIDARG;
}
GlobalUnlock ( stg.hGlobal );
ReleaseStgMedium ( &stg );
return hr;
}

要是我们返回 E_INVALIDARG, Explorer 将不会继续调用以后的扩展代码.
要是返回 S_OK, Explorer 将再一次调用QueryInterface() 获取另一个我们下面就要添加的接口指针: IContextMenu.

与上下文菜单交互的接口

一旦 Explorer 初始化了扩展,它就会接着调用 IContextMenu 的方法让我们添加菜单项, 提供状态栏上的提示, 并响应执行用户的选择.

添加IContextMenu 接口到Shell扩展类似于上面IshellExtInit接口的添加 .打开 SimpleShlExt.h,添加下列标红的代码:
class ATL_NO_VTABLE CSimpleShlExt :
public CComObjectRootEx,
public CComCoClass,
public IDispatchImpl,
public IShellExtInit,
public IContextMenu
{
BEGIN_COM_MAP(CSimpleShlExt)
COM_INTERFACE_ENTRY(ISimpleShlExt)
COM_INTERFACE_ENTRY(IDispatch)
COM_INTERFACE_ENTRY(IShellExtInit)
COM_INTERFACE_ENTRY(IContextMenu)
END_COM_MAP()

添加 IContextMenu 方法的函数原型:
public:
// IContextMenu
STDMETHOD(GetCommandString)(UINT, UINT, UINT*, LPSTR, UINT);
STDMETHOD(InvokeCommand)(LPCMINVOKECOMMANDINFO);
STDMETHOD(QueryContextMenu)(HMENU, UINT, UINT, UINT, UINT);

修改上下文菜单 IContextMenu 有三个方法.
第一个是 QueryContextMenu(), 它让我们可以修改上下文菜单. 其原型为:
HRESULT IContextMenu::QueryContextMenu ( HMENU hmenu, UINT uMenuIndex, UINT uidFirstCmd, UINT uidLastCmd, UINT uFlags );

hmenu 上下文菜单句柄.
uMenuIndex 是我们应该添加菜单项的起始位置.
uidFirstCmd uidLastCmd 是我们可以使用的菜单命令ID值的范围.
uFlags 标识了Explorer 调用QueryContextMenu()的原因,
这我以后会说到的.
而返回值根据你所查阅的文档的不同而不同.
Dino Esposito 的书中说返回值是你所添加的菜单项的个数.
VC6.0所带的MSDN 又说它是我们添加的最后一个菜单项的命令ID加上 1.
而最新的 MSDN 又说:
将返回值设为你为各菜单项分配的命令ID的最大差值,加上1.
例如, 假设 idCmdFirst 设为5,而你添加了三个菜单项 ,命令ID分别为 5, 7, 8.
这时返回值就应该是: MAKE_HRESULT(SEVERITY_SUCCESS, 0, 8 - 5 + 1).

我是一直按 Dino 的解释来做的, 而且工作得很好.
实际上, 他的方法与最新的 MSDN 是一致的, 只要你严格地使用 uidFirstCmd作为第一个菜单项的ID,再对接续的菜单项ID每次加1.

我们暂时的扩展仅加入一个菜单项,所以 QueryContextMenu() 非常简单:
HRESULT CSimpleShlExt::QueryContextMenu ( HMENU hmenu,UINT uMenuIndex,
UINT uidFirstCmd, UINT uidLastCmd, UINT uFlags )
{         
    // 如果标志包含 CMF_DEFAULTONLY 我们不作任何事情.
    if ( uFlags & CMF_DEFAULTONLY )
      {
        return MAKE_HRESULT ( SEVERITY_SUCCESS, FACILITY_NULL, 0 );
      }
           
      InsertMenu ( hmenu, uMenuIndex, MF_BYPOSITION, uidFirstCmd, _T("SimpleShlExt Test Item") );
      return MAKE_HRESULT ( SEVERITY_SUCCESS, FACILITY_NULL, 1 );
}

首先我们检查 uFlags.
你可以在 MSDN中到所有标志的解释, 但对于上下文菜单扩展而言, 只有一个值是重要的: CMF_DEFAULTONLY.
该标志告诉Shell命名空间扩展保留默认的菜单项,这时我们的Shell扩展就不应该加入任何定制的菜单项,这也是为什么此时我们要返回 0 的原因.
如果该标志没有被设置, 我们就可以修改菜单了 (使用 hmenu 句柄), 并返回 1 告诉Shell我们添加了一个菜单项.
在状态栏上显示提示帮助

下一个要被调用的IContextMenu 方法是 GetCommandString(). 如果用户是在浏览器窗口中右击文本文件,或选中一个文本文件后单击文件菜单时,状态栏会显示提示帮助.
我们的 GetCommandString() 函数将返回一个帮助字符串供浏览器显示.

GetCommandString() 的原型是:
HRESULT IContextMenu::GetCommandString ( UINT idCmd, UINT uFlags, UINT *pwReserved, LPSTR pszName, UINT cchMax );
idCmd 是一个以0为基数的计数器,标识了哪个菜单项被选择.
因为我们只有一个菜单项, 所以idCmd 总是0. 但如果我们添加了3个菜单项, idCmd 可能是 0, 1, 2.
uFlags 是另一组标志(我以后会讨论到的).
PwReserved 可以被忽略.
pszName 指向一个由Shell拥有的缓冲区,我们将把帮助字符串拷贝进该缓冲区.
cchMax 是该缓冲区的大小.
返回值是S_OK E_FAIL.
GetCommandString() 也可以被调用以获取菜单项的动作( "verb") .
verb 是个语言无关性字符串,它标识一个可以加于文件对象的操作。
ShellExecute()的文档中有详细的解释, 而有关verb的内容足以再写一篇文章, 简单的解释是:verb 可以直接列在注册表中( "open" "print"等字符串shell程序的编写流程), 也可以由上下文菜单扩展创建. 这样就可以通过调用ShellExecute()执行实现在Shell扩展中的代码.

不管怎样, 我说了这多只是为了解释清楚GetCommandString() 的作用.
如果 Explorer 要求一个帮助字符串,我们就提供给它. 如果 Explorer 要求一个verb, 我们就忽略它. 这就是 uFlags 参数的作用.
如果 uFlags 设置了GCS_HELPTEXT , Explorer 是在要求帮助字符串. 而且如果 GCS_UNICODE 被设置, 我们就必须返回一个Unicode字符串.

我们的 GetCommandString() 如下:
#include "atlconv.h"
// 为使用 ATL 字符串转换宏而包含的头文件
           
HRESULT CSimpleShlExt::GetCommandString( UINT idCmd, UINT uFlags,
UINT* pwReserved, LPSTR pszName, UINT cchMax )
{
            USES_CONVERSION;
            //检查 idCmd, 它必须是0,因为我们仅有一个添加的菜单项.
            if ( 0 != idCmd )
            return E_INVALIDARG;
           
            // 如果 Explorer 要求帮助字符串,就将它拷贝到提供的缓冲区中.
            if ( uFlags & GCS_HELPTEXT )
                  {
                          LPCTSTR szText = _T("This is the simple shell extension's help");             
                          if ( uFlags & GCS_UNICODE )
                            {
                              // 我们需要将 pszName 转化为一个 Unicode 字符串, 接着使用Unicode字符串拷贝 API.
                              lstrcpynW ( (LPWSTR) pszName, T2CW(szText), cchMax );
                            }
                          else
                              {
                              // 使用 ANSI 字符串拷贝API 来返回帮助字符串.
                              lstrcpynA ( pszName, T2CA(szText), cchMax );
                              }
                          return S_OK;
                }
            return E_INVALIDARG;
}
这里没有什么特别的代码; 我用了硬编码的字符串并把它转换为相应的字符集.
如果你从未使用过ATL字符串转化宏,你一定要学一下,因为当你传递Unicode字符串到COMOLE函数时,使用转化宏会很有帮助的.
我在上面的代码中使用了T2CW T2CA TCHAR 字符串分别转化为Unicode ANSI字符串.
函数开头处的USES_CONVERSION 宏其实声明了一个将被转化宏使用的局部变量.

要注意的一个问题是: lstrcpyn() 保证了目标字符串将以null为结束符.
这与C运行时(CRT) strncpy()不同. 当要拷贝的源字符串的长度大于或等于cchMax strncpy()不会添加一个 null 结束符.
我建议总使用lstrcpyn(), 这样你就不必在每一个strncpy()后加入检查保证字符 串以 null为结束符的代码.

执行用户的选择

IContextMenu 接口的最后一个方法是 InvokeCommand(). 当用户点击我们添加的菜单项时该方法将被调用. 其函数原型是:
HRESULT IContextMenu::InvokeCommand ( LPCMINVOKECOMMANDINFO pCmdInfo );
CMINVOKECOMMANDINFO 结构带有大量的信息, 但我们只关心 lpVerb hwnd 这两个成员.
lpVerb参数有两个作用它或是可被激发的verb(动作), 或是被点击的菜单项的索引值.
hwnd 是用户激活我们的菜单扩展时所在的浏览器窗口的句柄.

因为我们只有一个扩展的菜单项, 我们只要检查lpVerb 参数, 如果其值为0, 我们可以认定我们的菜单项被点击了.
我能想到的最简单的代码就是弹出一个信息框, 这里的代码也就做了这么多. 信息框显示所选的文件的文件名以证实代码正确地工作.
HRESULT CSimpleShlExt::InvokeCommand ( LPCMINVOKECOMMANDINFO pCmdInfo )
{
    // 如果lpVerb 实际指向一个字符串, 忽略此次调用并退出.
    if ( 0 != HIWORD( pCmdInfo->lpVerb ))
        return E_INVALIDARG;
    // 点击的命令索引在这里,唯一合法的索引为0.
    switch ( LOWORD( pCmdInfo->lpVerb ))
        {
        case 0:
            {
            TCHAR szMsg [MAX_PATH + 32];
            wsprintf ( szMsg, _T("The selected file was:\n\n%s"), m_szFile );
            MessageBox ( pCmdInfo->hwnd, szMsg, _T("SimpleShlExt"),
                        MB_ICONINFORMATION );
            return S_OK;
            }
        break;
        default:
            return E_INVALIDARG;
        break;
        }
}
注册Shell扩展
现在我们已经实现了所有需要的COM接口. 可是我们怎样才能让浏览器使用我们的扩展呢?
ATL 自动生成注册COM DLL服务器的代码, 但这只是让其它程序可以使用我们的DLL.
为了告诉浏览器使用我们的扩展, 我们需要在文本文件类型的注册表键下注册扩展:
HKEY_CLASSES_ROOT\txtfile
在这个键下, 有个名为 ShellEx 的键保存了一个与文本文件关联的Shell扩展的列表. ShellEx 键下, ContextMenuHandlers 键保存了上下文菜单扩展的列表.
每个扩展都在ContextMenuHandlers下创建了一个子键并将其默认值设为扩展COMGUID.
所以, 对于我们这个简单的扩展, 我们将创建下键:
HKEY_CLASSES_ROOT\txtfile\ShellEx\ContextMenuHandlers\SimpleShlExt
并将其默认值设为我们的 GUID: "{5E2121EE-0300-11D4-8D3B-444553540000}".

你不必写任何代码就可以完成注册操作. 如果你看一下Fileview页的文件列表, 你会看到s.
该文本文件将被ATL分析, 并指导ATL在该COM服务器注册时添加附加的注册键, 而注销时又该删除哪些键. 以下是所指定添加的注册表项:
HKCR
{
    NoRemove txtfile
    {
        NoRemove ShellEx
        {
            NoRemove ContextMenuHandlers
            {
                ForceRemove SimpleShlExt = s '{5E2121EE-0300-11D4-8D3B-444553540000}'
            }
        }
    }
}
每一行代表一个注册表键, "HKCR" HKEY_CLASSES_ROOT 的缩写.
NoRemove 关键字表示当该COM服务器注销时该键 不用被删除.
最后一行有些复杂. ForceRemove 关键字表示如果该键已存在, 那么在新键添加之前该键先应被删除.
这行脚本的余下部分指定一个字符串,它将被存为 SimpleShlExt 键的默认值.

在这我插几句话. 我们是在 HKCR\txtfile下注册的. 但是 "txtfile" 名并不是一个永久的或预定好的名称.
如果你看一下 HKCR\.txt, 该键的默认值正是txtfile. 这样就会有一些副作用:
1.我们不能可靠地使用 RGS 教本,因为 "txtfile" 可能不是正确的键名.
2. 一些文本编辑软件可能安装到系统并直接关联到 .TXT 文件. 如果它改变了HKCR\.txt 键的默认值, 所有现存的Shell扩展都将停止工作.

在我看来,这确是个设计上的错误. 我认为微软也是这么想的, 因为最新的扩展类型, QueryInfo扩展注册在 .txt 键下.

好了,到此为止. 最后还有一个注册细节. NT/2000, 我们还得将我们的扩展放到 "approved" 扩展列表中.
如果我们不这样做, 我们的扩展不会被没有管理员权限的用户调用. 该列表保存在:
HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Shell Extensions\Approved
在该键下, 我们要创建一个以我们的GUID 为名的字符串键,键的内容任意. 与之有关的代码在DllRegisterServer() DllUnregisterServer() 函数中.
只是些简单的注册表获取, 我也就不在这写出了. 你可以在例子工程代码中到它.

调试Shell 扩展
最终你会写一个不会这么简单的扩展, 那时你就不得不进行调试.
打开你的工程设置, Debug 页” Executable for debug session”编辑框中输入浏览器程序的全路径, 如:"C:\".
如果你使用的是 NT 2000, 并且你已经设置了上述的 DesktopProcess 注册键, 那么当你按F5进行调试时就会打开一个新的浏览器窗口.
只要你在这个窗口内完成你所有的工作,当你关闭该窗口时扩展同时会被卸出内存,这样就不会防碍我们重建 DLL.

Windows 9x, 在重新调试之前你不得不关闭Shell. 你可以: 点击 “开始”, 再点击”关闭”. 按住 Ctrl+Alt+Shift 并点击”取消”.
这样就会关闭Shell, 你会看到桌面消失了.
接着,你可以切换到 MSVC 再按 F5进行调试. 要中止调试, Shift+F5 关闭. 完成调试后, 你可以从”开始 运行” 以正常重起.

扩展的样子
下面就是我们添加自定义菜单项后的样子:

看,我们的菜单项在那!

下图是浏览器状态栏的帮助提示字符串的显示:


弹出信息框如下图, 显示了所选的文件名:


第一节完
本资料由同济大学戴维制作
资料从网上各处收集,版权归原作者所有,由戴维进行了整理与加工,部分不全的地方进行了翻译补全。
如需转载,请联系作者E-mail
E-mail:davidmails@126
davidsoft.6to23