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

编写 Shell 扩展的完整入门指南 - 第五部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (29投票s)

2000 年 4 月 8 日

viewsIcon

577549

downloadIcon

5202

本教程介绍如何编写一个向文件属性对话框添加页面的 Shell 扩展。

目录

引言

在本指南的第五部分,我们将探索属性表的世界。当您打开文件系统对象的属性时,资源管理器会显示一个带有“常规”选项卡的属性表。Shell 允许我们通过使用一种称为属性表处理程序的 Shell 扩展来向属性表添加页面。

本文假定您已了解 Shell 扩展的基础知识,并且熟悉 STL 集合类。如果您需要回顾 STL,应该阅读第二部分,因为本文将使用相同的技术。

请记住,VC 7(可能还有 VC 8)用户在编译前需要更改一些设置。请参阅 第一部分的 README 部分 以了解详细信息。

大家都熟悉资源管理器中的属性对话框。更具体地说,它们是包含一个或多个页面的属性表。每个属性表都有一个“常规”选项卡,其中列出了完整路径、修改日期和其他各种信息。资源管理器允许我们使用属性表处理程序扩展添加自己的页面到属性表中。属性表处理程序还可以添加或替换某些控制面板小程序中的页面,但这里将不讨论该主题。请参阅我的文章向控制面板小程序添加自定义页面以了解有关扩展小程序的信息。

本文介绍了一个扩展,允许您直接从文件属性对话框修改文件的创建、访问和修改时间。我将使用纯 SDK 调用处理所有属性页,不使用 MFC 或 ATL。我尚未尝试在扩展中使用 MFC 或 WTL 属性页对象;这样做可能会很棘手,因为 Shell 期望接收一个指向属性表(HPROPSHEETPAGE)的句柄,而 MFC 在 CPropertyPage 实现中隐藏了此细节。

如果您打开 .URL 文件(Internet 快捷方式)的属性,您可以看到属性表处理程序正在工作。 “CodeProject”选项卡是本文扩展的预览。 “Web Document”选项卡显示了 IE 安装的一个扩展。

 [Built-in prop sheet handler - 21K ]

初始化接口

您现在应该熟悉设置步骤了,所以我将跳过 VC 向导的说明。如果您正在使用向导,请创建一个名为 _FileTime_ 的新 ATL COM 应用程序,并使用 C++ 实现类 CFileTimeShlExt

由于属性表处理程序一次处理所有选定的文件,因此它使用 IShellExtInit 作为其初始化接口。我们需要将 IShellExtInit 添加到 CFileTimeShlExt 实现的接口列表中。同样,这应该对您来说很熟悉,所以我不会在这里重复这些步骤。

该类还需要一个字符串列表来存储所选文件的名称。

typedef list< basic_string<TCHAR> > string_list;
 
protected:
  string_list m_lsFiles;

Initialize() 方法将与第二部分相同 - 读取所选文件的名称并将其存储在字符串列表中。这是该函数的开头

STDMETHODIMP CFileTimeShlExt::Initialize (
  LPCITEMIDLIST pidlFolder, LPDATAOBJECT pDataObj,
  HKEY hProgID )
{
TCHAR     szFile[MAX_PATH];
UINT      uNumFiles;
HDROP     hdrop;
FORMATETC etc = { CF_HDROP, NULL, DVASPECT_CONTENT, -1, TYMED_HGLOBAL };
STGMEDIUM stg;
INITCOMMONCONTROLSEX iccex = { sizeof(INITCOMMONCONTROLSEX), ICC_DATE_CLASSES };
 
  // Init the common controls.
  InitCommonControlsEx ( &iccex );

我们初始化公用控件,因为我们的页面将使用日期/时间选择器 (DTP) 控件。接下来,我们进行所有关于 IDataObject 接口的操作,并获取一个 HDROP 句柄来枚举选定的文件。

  // Read the list of items from the data object.  They're stored in HDROP
  // form, so just get the HDROP handle and then use the drag 'n' drop APIs
  // on it.
  if ( FAILED( pDataObj->GetData ( &etc, &stg ) ))
    return E_INVALIDARG;
 
  // Get an HDROP handle.
  hdrop = (HDROP) GlobalLock ( stg.hGlobal );
 
  if ( NULL == hdrop )
    {
    ReleaseStgMedium ( &stg );
    return E_INVALIDARG;
    }
 
  // Determine how many files are involved in this operation.
  uNumFiles = DragQueryFile ( hdrop, 0xFFFFFFFF, NULL, 0 );

接下来是实际枚举所选文件的循环。此扩展仅适用于文件,而不适用于目录,因此我们会忽略遇到的任何目录。

  for ( UINT uFile = 0; uFile < uNumFiles; uFile++ )
    {
    // Get the next filename.
    if ( 0 == DragQueryFile ( hdrop, uFile, szFile, MAX_PATH ) )
      continue;
 
    // Skip over directories.  We *could* handle directories, since they
    // keep the creation time/date, but I'm just choosing not to do so
    // in this example.
    if ( PathIsDirectory ( szFile ) )
      continue;
 
    // Add the filename to our list of files to act on.
    m_lsFiles.push_back ( szFile );
    }   // end for
 
  // Release resources.
  GlobalUnlock ( stg.hGlobal );
  ReleaseStgMedium ( &stg );

枚举文件名的代码与之前相同,但这里也有一些新内容。属性表对可以拥有的页面数量有限制,该限制在 prsht.h 中定义为常量 MAXPROPPAGES。每个文件将获得自己的页面,因此如果我们的列表包含的文件数超过 MAXPROPPAGES,它将被截断,使其大小为 MAXPROPPAGES。(尽管 MAXPROPPAGES 当前为 100,但属性表不会显示这么多选项卡。它最多显示大约 34 个。)

  // Check how many files were selected.  If the number is greater than the
  // maximum number of property pages, truncate our list.
  if ( m_lsFiles.size() > MAXPROPPAGES )
    m_lsFiles.resize ( MAXPROPPAGES );
 
  // If we found any files we can work with, return S_OK.  Otherwise,
  // return E_FAIL so we don't get called again for this right-click
  // operation.
  return (m_lsFiles.size() > 0) ? S_OK : E_FAIL;
}

添加属性页

如果 Initialize() 返回 S_OK,则资源管理器会查询一个新的接口 IShellPropSheetExtIShellPropSheetExt 非常简单,只有一个需要实现的方法。要将 IShellPropSheetExt 添加到我们的类中,请打开 _FileTimeShlExt.h_ 并添加此处以粗体显示的行

class CFileTimeShlExt :
  public CComObjectRootEx<CComSingleThreadModel>,
  public CComCoClass<CFileTimeShlExt, &CLSID_FileTimeShlExt>,
  public IShellExtInit,
  public IShellPropSheetExt
{
  BEGIN_COM_MAP(CFileTimeShlExt)
    COM_INTERFACE_ENTRY(IShellExtInit)
    COM_INTERFACE_ENTRY(IShellPropSheetExt)
  END_COM_MAP()
 
public:
  // IShellPropSheetExt
  STDMETHODIMP AddPages(LPFNADDPROPSHEETPAGE, LPARAM);
  STDMETHODIMP ReplacePage(UINT, LPFNADDPROPSHEETPAGE, LPARAM)
      { return E_NOTIMPL; }

AddPages() 方法是我们来实现的。 ReplacePage() 仅由替换控制面板小程序中页面的扩展使用,因此我们在此处无需实现它。资源管理器调用我们的 AddPages() 函数,允许我们向资源管理器设置的属性表添加页面。

AddPages() 的参数是一个函数指针和一个 LPARAM,两者都仅由 Shell 使用。 lpfnAddPageProc 指向 Shell 内部的一个函数,我们调用它来实际添加页面。 lParam 是 Shell 的一个神秘值。我们不处理它,只将其传递回 lpfnAddPageProc 函数。

STDMETHODIMP CFileTimeShlExt::AddPages (
  LPFNADDPROPSHEETPAGE lpfnAddPageProc,
  LPARAM lParam )
{
PROPSHEETPAGE  psp;
HPROPSHEETPAGE hPage;
TCHAR          szPageTitle [MAX_PATH];
string_list::const_iterator it, itEnd;
                                   
  for ( it = m_lsFiles.begin(), itEnd = m_lsFiles.end();
        it != itEnd; it++ )
    {
    // 'it' points at the next filename. Allocate a new copy of the string
    // that the page will own.
    LPCTSTR szFile = _tcsdup ( it->c_str() );

我们做的第一件事是复制文件名。原因如下。

下一步是创建一个字符串用于我们页面的选项卡。该字符串将是文件名,不带扩展名。此外,如果字符串超过 24 个字符,它将被截断。这完全是任意的;我选择了 24,因为它看起来不错。应该有 _某种_ 限制,以防止名称超出选项卡的末尾。

    // Strip the path and extension from the filename - this will be the
    // page title.  The name is truncated at 24 chars so it fits on the tab.
    lstrcpyn ( szPageTitle, it->c_str(), MAX_PATH );
    PathStripPath ( szPageTitle );
    PathRemoveExtension ( szPageTitle );
    szPageTitle[24] = '\0';

由于我们将使用纯 SDK 调用来处理属性页,因此我们将不得不接触 PROPSHEETPAGE 结构。这是该结构的设置

    psp.dwSize      = sizeof(PROPSHEETPAGE);
    psp.dwFlags     = PSP_USEREFPARENT | PSP_USETITLE |
                        PSP_USEICONID | PSP_USECALLBACK;
    psp.hInstance   = _Module.GetResourceInstance();
    psp.pszTemplate = MAKEINTRESOURCE(IDD_FILETIME_PROPPAGE);
    psp.pszIcon     = MAKEINTRESOURCE(IDI_TAB_ICON);
    psp.pszTitle    = szPageTitle;
    psp.pfnDlgProc  = PropPageDlgProc;
    psp.lParam      = (LPARAM) szFile;
    psp.pfnCallback = PropPageCallbackProc;
    psp.pcRefParent = (UINT*) &_Module.m_nLockCnt;

这里有几个重要细节需要我们特别注意,以确保扩展能够正确工作

  1. pszIcon 成员设置为 16x16 图标的资源 ID,该图标将显示在选项卡中。拥有图标当然是可选的,但我添加了一个图标,使我们的页面更加醒目。
  2. pfnDlgProc 成员设置为我们页面的对话框过程的地址。
  3. lParam 成员设置为 szFile,这是与页面关联的文件名的副本。
  4. pfnCallback 成员设置为一个回调函数的地址,该函数在页面创建和销毁时被调用。该函数的作用将在后面解释。
  5. pcRefParent 成员设置为从 CComModule 继承的成员变量的地址。此变量是 DLL 的锁定计数。当显示属性表时,Shell 会增加此计数,以在属性表打开期间将我们的 DLL 保存在内存中。计数将在属性表销毁后递减。

设置好该结构后,我们调用 API 来创建属性页。

    hPage = CreatePropertySheetPage ( &psp );

如果成功,我们调用 Shell 的回调函数,该函数将新创建的页面添加到属性表中。回调返回一个 BOOL,指示成功或失败。如果失败,我们销毁该页面。

    if ( NULL != hPage )
      {
      // Call the shell's callback function, so it adds the page to
      // the property sheet.
      if ( !lpfnAddPageProc ( hPage, lParam ) )
        DestroyPropertySheetPage ( hPage );
      }
    }   // end for
 
  return S_OK;
}

对象的生命周期带来的棘手问题

现在是时候兑现我关于重复字符串的承诺了。需要重复是因为在 AddPages() 返回后,Shell 会释放其 IShellPropSheetExt 接口,这反过来会销毁 CFileTimeShlExt 对象。这意味着属性页的对话框过程无法访问 CFileTimeShlExtm_lsFiles 成员。

我的解决方案是复制每个文件名,并将指向该副本的指针传递给页面。页面拥有该内存,并负责释放它。如果有一个以上的文件被选中,每个页面都会获得与其关联的文件名的副本。内存将在后面显示的 PropPageCallbackProc 函数中释放。AddPages() 中的这行

  psp.lParam = (LPARAM) szFile;

是重要的。它将指针存储在 PROPSHEETPAGE 结构中,并使其可供页面的对话框过程访问。

属性页回调函数

现在,让我们来看看属性页本身。这是新页面看起来的样子。在阅读关于页面如何工作的解释时,请牢记这张图片。

 [Our new property page - 25K]

请注意,没有最后访问时间控件。FAT 只保留最后访问日期。其他文件系统会保留时间,但我没有实现检查文件系统的逻辑。如果文件系统支持最后访问时间字段,时间将始终存储为午夜 12 点。

该页面有两个回调函数和两个消息处理函数。这些原型位于 _FileTimeShlExt.cpp_ 的顶部

BOOL CALLBACK PropPageDlgProc ( HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam );
UINT CALLBACK PropPageCallbackProc ( HWND hwnd, UINT uMsg, LPPROPSHEETPAGE ppsp );
BOOL OnInitDialog ( HWND hwnd, LPARAM lParam );
BOOL OnApply ( HWND hwnd, PSHNOTIFY* phdr );

对话框过程很简单。它处理三个消息:WM_INITDIALOGPSN_APPLYDTN_DATETIMECHANGE。这是 WM_INITDIALOG 部分

BOOL CALLBACK PropPageDlgProc ( HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam )
{
BOOL bRet = FALSE;
 
  switch ( uMsg )
    {
    case WM_INITDIALOG:
      bRet = OnInitDialog ( hwnd, lParam );
    break;

OnInitDialog() 稍后会解释。接下来是 PSN_APPLY,如果用户单击“确定”或“应用”按钮,则会发送此消息。

    case WM_NOTIFY:
      {
      NMHDR* phdr = (NMHDR*) lParam;
 
      switch ( phdr->code )
        {
        case PSN_APPLY:
          bRet = OnApply ( hwnd, (PSHNOTIFY*) phdr );
        break;

最后是 DTN_DATETIMECHANGE。这个很简单——我们通过向属性表(我们页面的父窗口)发送消息来启用“应用”按钮。

        case DTN_DATETIMECHANGE:
          // If the user changes any of the DTP controls, enable
          // the Apply button.
          SendMessage ( GetParent(hwnd), PSM_CHANGED, (WPARAM) hwnd, 0 );
        break;
        }  // end switch
      }  // end case WM_NOTIFY
    break;
    }  // end switch
 
  return bRet;
}

到目前为止一切顺利。另一个回调函数在页面创建或销毁时被调用。我们只关心后者,因为那时我们可以释放回退到 AddPages() 中创建的重复字符串。 ppsp 参数指向用于创建页面的 PROPSHEETPAGE 结构,lParam 成员仍然指向必须释放的重复字符串。

UINT CALLBACK PropPageCallbackProc ( HWND hwnd, UINT uMsg, LPPROPSHEETPAGE ppsp )
{
  if ( PSPCB_RELEASE == uMsg )
    free ( (void*) ppsp->lParam );
 
  return 1;
}

该函数始终返回 1,因为当函数在页面创建期间被调用时,它可以返回 0 来阻止页面被创建。返回 1 会使页面正常创建。当函数在页面销毁时被调用时,将忽略返回值。

属性页消息处理函数

OnInitDialog() 中发生了许多重要的事情。 lParam 参数再次指向用于创建此页面的 PROPSHEETPAGE 结构。 _它的_ lParam 成员指向那个始终存在的文件名。由于我们需要在 OnApply() 函数中访问该文件名,因此我们使用 SetWindowLong() 保存该指针。

BOOL OnInitDialog ( HWND hwnd, LPARAM lParam )
{        
PROPSHEETPAGE*  ppsp = (PROPSHEETPAGE*) lParam;
LPCTSTR         szFile = (LPCTSTR) ppsp->lParam;
HANDLE          hFind;
WIN32_FIND_DATA rFind;
 
  // Store the filename in this window's user data area, for later use.
  SetWindowLong ( hwnd, GWL_USERDATA, (LONG) szFile );

接下来,我们使用 FindFirstFile() 获取文件的创建、修改和访问时间。如果成功,则 DTP 控件将使用正确的数据进行初始化。

  hFind = FindFirstFile ( szFile, &rFind );
 
  if ( INVALID_HANDLE_VALUE != hFind )
    {
    // Initialize the DTP controls.
    SetDTPCtrl ( hwnd, IDC_MODIFIED_DATE, IDC_MODIFIED_TIME,
                 &rFind.ftLastWriteTime );
 
    SetDTPCtrl ( hwnd, IDC_ACCESSED_DATE, 0,
                 &rFind.ftLastAccessTime );
 
    SetDTPCtrl ( hwnd, IDC_CREATED_DATE, IDC_CREATED_TIME,
                 &rFind.ftCreationTime );
 
    FindClose ( hFind );
    }

SetDTPCtrl() 是一个实用函数,用于设置 DTP 控件的内容。您可以在 _FileTimeShlExt.cpp_ 的末尾找到代码。

作为额外的润色,文件的完整路径将显示在页面顶部的静态控件中。

  PathSetDlgItemPath ( hwnd, IDC_FILENAME, szFile );
  return FALSE;
}

OnApply() 处理程序执行相反的操作——它读取 DTP 控件并将文件的创建、修改和访问时间写回文件。第一步是使用 GetWindowLong() 检索文件名指针并以写入模式打开文件。

BOOL OnApply ( HWND hwnd, PSHNOTIFY* phdr )
{
LPCTSTR  szFile = (LPCTSTR) GetWindowLong ( hwnd, GWL_USERDATA );
HANDLE   hFile;
FILETIME ftModified, ftAccessed, ftCreated;
 
  // Open the file.
  hFile = CreateFile ( szFile, GENERIC_WRITE, FILE_SHARE_READ, NULL,
                       OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL );

如果我们能打开文件,我们将读取 DTP 控件并将时间写回文件。 ReadDTPCtrl()SetDTPCtrl() 的对应项。

  if ( INVALID_HANDLE_VALUE != hFile )
    {
    // Retrieve the dates/times from the DTP controls.
    ReadDTPCtrl ( hwnd, IDC_MODIFIED_DATE, IDC_MODIFIED_TIME, &ftModified );
    ReadDTPCtrl ( hwnd, IDC_ACCESSED_DATE, 0, &ftAccessed );
    ReadDTPCtrl ( hwnd, IDC_CREATED_DATE, IDC_CREATED_TIME, &ftCreated );
 
    // Change the file's created, accessed, and last modified times.
    SetFileTime ( hFile, &ftCreated, &ftAccessed, &ftModified );
    CloseHandle ( hFile );
    }
  else
    // <<Error handling omitted>>
 
  // Return PSNRET_NOERROR to allow the sheet to close if the user clicked OK.
  SetWindowLong ( hwnd, DWL_MSGRESULT, PSNRET_NOERROR );
  return TRUE;
}

注册外壳扩展

注册拖放处理程序类似于注册上下文菜单扩展。可以为特定文件类型调用处理程序,例如所有文本文件。此扩展适用于 _任何_ 文件,因此我们将其注册在 HKEY_CLASSES_ROOT\* 键下。这是注册扩展的 RGS 脚本

HKCR
{
  NoRemove *
  {
    NoRemove shellex
    {
      NoRemove PropertySheetHandlers
      {
        {3FCEF010-09A4-11D4-8D3B-D12F9D3D8B02}
      }
    }
  }
}

您可能会注意到扩展的 GUID 在此处存储为注册表项的名称,而不是字符串值。我查阅的文档和书籍在正确的命名约定上存在冲突,尽管在我简短的测试中,两种方法都有效。我已决定采用 Dino Esposito 的书(Visual C++ Windows Shell Programming)中的方式,并将 GUID 放在注册表项的名称中。

与往常一样,在基于 NT 的操作系统上,我们需要将我们的扩展添加到“已批准”扩展列表中。执行此操作的代码位于示例项目中的 DllRegisterServer()DllUnregisterServer() 函数中。

待续...

在第六部分中,我们将看到另一种新型扩展——拖放处理程序,当 Shell 对象被拖放到文件上时会调用它。

版权和许可

本文是受版权保护的材料,©2000-2006 Michael Dunn。我知道这不会阻止人们在网上随意复制它,但我还是必须说出来。如果您有兴趣翻译本文,请发送电子邮件给我告知。我预计不会拒绝任何人翻译的许可,我只是想知道翻译情况,以便在此处发布链接。

本文附带的演示代码已发布到公共领域。我以这种方式发布它,以便代码能让所有人受益。(我不将文章本身设为公共领域,因为只有在 CodeProject 上提供文章有助于提高我的知名度和 CodeProject 网站的流量。)如果您将演示代码用于自己的应用程序,非常感谢您发送电子邮件告知我(只是为了满足我对人们是否从我的代码中受益的好奇心),但这不是必需的。在您自己的源代码中注明出处也是受欢迎的,但不是必需的。

修订历史

2000 年 4 月 8 日:文章首次发布。
2000 年 6 月 6 日:更新了某些内容。;)
2006 年 5 月 25 日:更新以涵盖 VC 7.1 的更改,清理代码片段,演示项目在 XP 上进行了主题化。

系列导航:« 第四部分 | 第六部分 »

© . All rights reserved.