作者:朱金灿
关于什么叫Windowsshell扩展程序,这里不作介绍,不懂的同学请google之。
一.Shell程序编写
这里采用的开发环境为WindowsXP+sp3, VS 2005 + sp1 (应该支持VS 2005以上的VS版本,VC 6.0估计不支持)。
1.新建一个ATL项目,输入工程名:ImportShell,具体如下图:
2. 在应用程序设置中的服务器类型中选择:动态链接库(DLL),其它选项采用默认设置,具体如下图:
这样单击完成后就新建了ATL工程。
3.新建一个ATL简单对象(英文版的VS为ATLSimple Object),具体如下图:
4.输入一个简称:ImportShellExt,其它的VS会帮你自动填写,具体如下图:
新建CImportShellExt类需要新继承两个基类:IShellExtInit和IContextMenu。新加的接口函数主要有四个:
当我们的shell扩展被加载时, Explorer 将调用我们所实现的COM对象的 QueryInterface() 函数以取得一个 IShellExtInit 接口指针.
该接口仅有一个方法 Initialize(), 其函数原型为:
HRESULTIShellExtInit::Initialize ( LPCITEMIDLIST pidlFolder, LPDATAOBJECT pDataObj,HKEY hProgID );
Explorer 使用该方法传递给我们各种各样的信息.
PidlFolder是用户所选择操作的文件所在的文件夹的 PIDL 变量. (一个 PIDL [指向ID列表的指针] 是一个数据结构,它唯一地标识了在Shell命名空间的任何对象, 一个Shell命名空间中的对象可以是也可以不是真实的文件系统中的对象.)
pDataObj 是一个IDataObject 接口指针,通过它我们可以获取用户所选择操作的文件名。
hProgID 是一个HKEY注册表键变量,可以用它获取我们的DLL的注册数据.
一旦 Explorer 初始化了扩展,它就会接着调用 IContextMenu 的方法让我们添加菜单项, 提供状态栏上的提示, 并响应执行用户的选择。
添加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 CImportShellExt::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("工程入库") );
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"等字符串), 也可以由上下文菜单扩展创建. 这样就可以通过调用ShellExecute()执行实现在Shell扩展中的代码.
不管怎样, 我说了这多只是为了解释清楚GetCommandString() 的作用.
如果 Explorer 要求一个帮助字符串,我们就提供给它. 如果 Explorer 要求一个verb, 我们就忽略它. 这就是 uFlags 参数的作用.
如果 uFlags 设置了GCS_HELPTEXT 位,则 Explorer 是在要求帮助字符串. 而且如果 GCS_UNICODE 被设置,我们就必须返回一个Unicode字符串.
我们的 GetCommandString() 如下:
#include <atlconv.h>
// 为使用 ATL 字符串转换宏而包含的头文件
HRESULT CImportShellExt::GetCommandString( UINT idCmd, UINT uFlags,UINT* pwReserved, LPSTR pszName, UINT cchMax )
{
USES_CONVERSION;
//检查idCmd, 它必须是,因为我们仅有一个添加的菜单项.
if ( 0 != idCmd )
return E_INVALIDARG;
// 如果Explorer 要求帮助字符串,就将它拷贝到提供的缓冲区中.
if ( uFlags & GCS_HELPTEXT )
{
LPCTSTR szText = _T("统计文件夹中的文件个数");
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字符串到COM和OLE函数时,使用转化宏会很有帮助的.
我在上面的代码中使用了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 CImportShellExt::InvokeCommand( LPCMINVOKECOMMANDINFO pCmdInfo )
{
// If lpVerb really points to a string, ignore this function call and bail out.
if ( 0 != HIWORD( pCmdInfo->lpVerb ) )
return E_INVALIDARG;
// Get the command index - the only valid one is 0.
switch ( LOWORD( pCmdInfo->lpVerb) )
{
case 0:
{
TCHAR szMsg [MAX_PATH + 32];
wsprintf ( szMsg, _T("选中的文件夹为%s"),m_szFile);
MessageBox ( pCmdInfo->hwnd, szMsg, _T("信息"),
MB_ICONINFORMATION );
return S_OK;
}
break;
default:
return E_INVALIDARG;
break;
}
}
这时可能你会问:操作系统是如何知道我们要插入这个菜单的?这里涉及到一个COM组件的注册问题。所谓COM组件的注册,简单来说是将COM组件的相关信息写进注册表,然后操作系统通过读取注册表的相关信息来加载COM组件。Shell程序的注册分为两步:
第一步在Win NT/Win 2000上确保你的Shell扩展能被没有管理员权限的用户调用,需要在注册表HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\ShellExtensions\Approved添加我们的程序信息。这个需要在工程中的DllRegisterServer函数(注册函数)和DllUnregisterServer函数(反注册函数)。代码如下:
// DllRegisterServer - 将项添加到系统注册表
STDAPI DllRegisterServer(void)
{
// 注册对象、类型库和类型库中的所有接口
if ( 0 == (GetVersion() & 0x80000000UL) )
{
CRegKey reg;
LONG lRet;
lRet = reg.Open ( HKEY_LOCAL_MACHINE,
_T("Software\\Microsoft\\Windows\\CurrentVersion\\Shell Extensions\\Approved"),
KEY_SET_VALUE );
if ( ERROR_SUCCESS != lRet )
return E_ACCESSDENIED;
lRet = reg.SetValue ( _T("ImportShell extension"),
_T("{06001B8E-8858-4CEE-8E91-60E12A6C81A7}") );
if ( ERROR_SUCCESS != lRet )
return E_ACCESSDENIED;
}
HRESULT hr = _AtlModule.DllRegisterServer();
return hr;
}
// DllUnregisterServer - 将项从系统注册表中移除
STDAPI DllUnregisterServer(void)
{
if ( 0 == (GetVersion() & 0x80000000UL) )
{
CRegKey reg;
LONG lRet;
lRet = reg.Open ( HKEY_LOCAL_MACHINE,
_T("Software\\Microsoft\\Windows\\CurrentVersion\\Shell Extensions\\Approved"),
KEY_SET_VALUE );
if ( ERROR_SUCCESS == lRet )
{
lRet = reg.DeleteValue ( _T("{06001B8E-8858-4CEE-8E91-60E12A6C81A7}") );
}
}
HRESULT hr = _AtlModule.DllUnregisterServer();
return hr;
}
这里的一个问题是reg.SetValue ( _T("ImportShell extension"),
_T("{06001B8E-8858-4CEE-8E91-60E12A6C81A7}") );
中的键名和键值是如何来的。实际上当你新建COM简单对象后,就会自动生成一个ImportShellExt.rgs文件,打开这个ImportShellExt.rgs文件,就会有如下的文件:
ImportShell.ImportShellExt.1 = s 'ImportShellExt Class'
{
CLSID = s '{06001B8E-8858-4CEE-8E91-60E12A6C81A7}'
}
ImportShell.ImportShellExt = s 'ImportShellExt Class'
{
CLSID = s '{06001B8E-8858-4CEE-8E91-60E12A6C81A7}'
CurVer = s 'ImportShell.ImportShellExt.1'
}
这个键名一般取自程序名+ extension,如ImportShell extension,键值则来自它的guid的字符串形式: {06001B8E-8858-4CEE-8E91-60E12A6C81A7}。
第二步则涉及到该Shell程序所操作的文件类型。比如我们要求它在选中文件夹才弹出我们这个右键菜单。这时就需要在ImportShellExt.rgs文件添加一些信息:
NoRemove Folder
{
NoRemove ShellEx
{
NoRemove ContextMenuHandlers
{
ForceRemove ImportShellExt = s'{06001B8E-8858-4CEE-8E91-60E12A6C81A7}'
}
}
}
上面这个其实很好理解的:每一行代表一个注册表键, "HKCR"是HKEY_CLASSES_ROOT 的缩写.
NoRemove 关键字表示当该COM服务器注销时该键 不用被删除.
最后一行有些复杂. ForceRemove 关键字表示如果该键已存在, 那么在新键添加之前该键先应被删除. 这行脚本的余下部分指定一个字符串,它将被存为ImportShell键的默认值。
如果你要操作txt文件,可以添加这样的信息:
NoRemove .txt
{
NoRemove ShellEx
{
NoRemove ContextMenuHandlers
{
ForceRemove ImportShellExt = s'{06001B8E-8858-4CEE-8E91-60E12A6C81A7}'
}
}
}
如果要操作任意类型的文件,则是:
NoRemove *
{
NoRemove ShellEx
{
NoRemove ContextMenuHandlers
{
ForceRemove ImportShellExt = s'{06001B8E-8858-4CEE-8E91-60E12A6C81A7}'
}
}
}
二.Shell程序调试
在Win NT/2000上, 你可以找到如下键:
HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer
并创建一个名为DesktopProcess的DWORD值 1. 这会使桌面和任务栏运行在同一个进程中, 而其他每一个Explorer 窗口都运行在它自己的每一个进程内. 也就是说,你可以在单个的Explorer窗口内进行调试, 而后只要你关闭该窗口,你的DLL就会被马上卸载, 这就避免了因为DLL正被Windows使用而无法替换更新. 而如果不幸出现这种情况,你就不得不注销登录后再重新登录进Windows从而强制卸载使用中的Shell扩展DLL。
按F5开始,这时会弹出一个对话框,这时请输入exploer.exe的路径,如下图:
这时一般会出现一个警告框,按是不予理会,如下图:
接着是打开一个我的文档的窗口,如下图:
这时就可以在代码中设置断点调试了。
三.Shell程序的部署
Shell程序的部署很简单,就是在生成的dll的目录下新建两个批处理文件:
install.bat ——shell程序的安装脚本,内容为:
regsvr32.exe ImportShell.dll
uninstall.bat ——shell程序的卸载脚本,内容为:
regsvr32.exe /u ImportShell.dll
运行这两个批处理文件就能安装或卸载shell程序。
四.遇到问题及解决办法
链接器工具错误 LINK : fatal error LNK1168: cannot open..\outdir\Debug\ImportShell.dll for writing。
在改变注册com对象的guid会出现该问题。解决办法是打开任务管理器,杀死所有explorer.exe,然后新建一个explorer进程。