大家應該都知道,微軟在 Windows 11 中對右鍵選單進行了一番大改造,從 XP 時代就開始的經典設計被換成了全新的操作模式和樣式。這種突如其來的變化讓不少人感到不適應,甚至有些人試圖透過修改機碼的方式,把舊版的右鍵選單找回來。但今天我想跟大家分享的不是如何恢復舊版選單,而是如何在新版的右鍵選單中添加自定義選項。

要完成這項任務,我們將會涉及到 C++ 語言的使用。我得坦白說,我本人並沒有寫過 C++,所以這次的範例大多是參考了 Notepad++ 的 NppShell.dll 的開源碼,然後再透過 ChatGPT 一點一滴的學習和調整,所以可能有些許的錯誤就請多多包涵。

NppShell Github 專案:
NppShell Github 專案




如何實現 Windows 11 自定義選單

要在 Windows 11 中實現自定義右鍵選單,根據微軟在開發者部落格的介紹,我們可以使用以下兩種方式實現:
  1. 利用 IExplorerCommand 介面:IExplorerCommand 介面是 Windows Shell 擴展的一部分,專門用於定義和控制檔案總管中的右鍵選單命令。
  2. 使用 Sparse Package 技術:在早期的 Windows 版本中,傳統 Win32 應用程式是主要的應用程式類型,它們通常不包含對操作系統深度整合功能的存取,例如:「背景任務」「通知」「動態磚」「分享」等。而這些功能主要是針對 UWP(通用 Windows 平台)應用程式設計的,它們具有更深層次的操作系統整合。所以在 Windows 新一點的版本中,微軟試圖縮小傳統 Win32 應用程式與這些新 Windows API 和功能之間的差距,因而引入Sparse Package技術。它允許這些傳統應用程式維持它們現有的檔案布局和部署方式,同時獲得對新 Windows API 的存取能力。
關於完整的 Sparse Package 介紹可以參考微軟的開發者部落格:



準備工具

首先我們需要準備 Visual Studio 2022 並且需要安裝 C++ 的工具。
接著還需要安裝 Windows 11 的 SDK(軟體開發套件)。透過這個連結【點我前往】去下載並安裝。SDK 是開發 Windows 應用時必備的一套工具和庫,它讓我們可以更方便地存取系統功能。安裝完 SDK 之後可以到此路徑中「C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64」找到兩個稍後(Sparse Package 實作中會介紹)會用到的工具:「makeappx.exe」「signtool.exe」這些工具將幫助我們在開發過程中打包和簽署應用。



安裝 NuGet 套件和專案設定

在 Visual Studio 2022 中,你需要建立一個新的 DLL 專案。選擇 C++ 作為開發語言。DLL(Dynamic Link Library)專案將允許我們創建可由 Windows 操作系統加載和執行的程式庫文件。
專案建立完成後,需要安裝以下兩個 NuGet 套件:
  1. Microsoft.Windows.CppWinRT:這個套件提供了 C++/WinRT 的支援,使用符合標準的 C++17 編譯器,用於 Windows Runtime(WinRT)API。這讓我們能夠更容易地在 C++ 中調用 WinRT API。
  2. Microsoft.Windows.ImplementationLibrary:這個套件則提供了實現 Windows API 程式庫的工具,幫助我們在專案中更加方便地實現 Windows 功能。
完成了 DLL 專案的建立和 NuGet 套件的安裝之後,我們的下一步是設定專案中的 C++ 語言標準。在專案的屬性中,將 C++ 標準語音設定為「C++20」



實作 IExplorerCommand 右鍵選單

接下來的重點就是實作自定義的右鍵選單。要成功地在 Windows 11 中添加自己的選單項目,我們需要利用 Windows 的 IExplorerCommand 接口來實現這個功能。它是 Windows Shell 擴展的一部分,它允許開發者定義和控制在檔案總管的上下文選單(也就是右鍵選單)中出現的命令。
在這個類別中,你需要實做一些必要的方法:
namespace Win11ContextMenuDemo::ExplorerCommand
{
    // BaseExplorerCommand 是一個基礎類,用於實現 IExplorerCommand 介面。
    // BaseExplorerCommand is a base class for implementing the IExplorerCommand interface.
    class BaseExplorerCommand : public winrt::implements<BaseExplorerCommand, IExplorerCommand>
    {
    protected:
        // 獲取多語言標題的方法。
        // Method for obtaining the multi-language title.
        virtual const wstring GetMultiLanguageTitle();

        // 獲取圖標路徑的方法。
        // Method for obtaining the icon path.
        virtual const wstring GetIconPath();

        // 用於存儲標題資源 ID 的成員變數。
        // Member variable to store the title resource ID.
        UINT titleResourceID = ID_WIN11CONTEXTMENUDEMO_DEFAULT;

        // 用於存儲圖標檔案名的成員變數。
        // Member variable to store the icon file name.
        wstring iconFileName = L"";

    public:
        // 設置標題資源 ID 的方法。
        // Method to set the title resource ID.
        virtual void SetTitleResourceID(UINT id);

        // 設置圖標檔案名的方法。
        // Method to set the icon file name.
        virtual void SetIconFileName(wstring fileName);

        // 獲取命令的標題。
        // Get the title of the command.
        virtual IFACEMETHODIMP GetTitle(IShellItemArray* psiItemArray, LPWSTR* ppszName);

        // 獲取命令的圖標。
        // Get the icon of the command.
        virtual IFACEMETHODIMP GetIcon(IShellItemArray* psiItemArray, LPWSTR* ppszIcon);

        // 獲取命令的工具提示。
        // Get the tooltip of the command.
        virtual IFACEMETHODIMP GetToolTip(IShellItemArray* psiItemArray, LPWSTR* ppszInfotip);

        // 獲取命令的狀態。
        // Get the state of the command.
        virtual IFACEMETHODIMP GetState(IShellItemArray* psiItemArray, BOOL fOkToBeSlow, EXPCMDSTATE* pCmdState);

        // 獲取命令的旗標。
        // Get the flags of the command.
        virtual IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags);

        // 獲取命令的標準名稱。
        // Get the canonical name of the command.
        virtual IFACEMETHODIMP GetCanonicalName(GUID* pguidCommandName);

        // 枚舉子命令。
        // Enumerate sub-commands.
        virtual IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** ppEnum);

        // 執行命令的方法,必須由派生類實現。
        // The method to execute the command, which must be implemented by derived classes.
        IFACEMETHODIMP Invoke(IShellItemArray* psiItemArray, IBindCtx* pbc) noexcept override;
    };
}
  • GetTitle:用於獲取選單項目的標題。
  • GetIcon:設定選單圖標。
  • GetFlags:設定選單是的旗標。
    💡 這個用於設定是否為普通選單或是包含子項目的選單。
  • EnumSubCommands:這個方法負責創建和管理子選單。
  • Invoke:定義選擇該選單項目時執行的指令動作。
不論是主選單還是子選單,我們都會利用實現 IExplorerCommand 介面的方式來建立各種選項。為了提高開發效率和維護的方便性,因此我創建的是一個 BaseExplorerCommand 的基礎類別,這樣就能夠方便地通過繼承來實作所需的主選單(Main)和子選單(Sub)。

在主選單中,不需要執行任何命令,因此不需要實作「Invoke」。但是需要實作「GetFlags」「EnumSubCommands」來實現子選單。
#include "BaseExplorerCommand.h"

namespace Win11ContextMenuDemo::ExplorerCommand
{
    // MainExplorerCommand 是用於實現主選單的類別,繼承自 BaseExplorerCommand。
    // MainExplorerCommand is a class used to implement the main menu, inheriting from BaseExplorerCommand.
    class __declspec(uuid("15589FA6-768B-4826-97B8-D12DE265B3BB")) MainExplorerCommand : public BaseExplorerCommand
    {
    public:
        // 構造函數。
        // Constructor.
        MainExplorerCommand();

        // 獲取主選單的旗標。
        // Get the flags of the main menu.
        IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override;

        // 枚舉主選單的子命令。
        // Enumerate the sub-commands of the main menu.
        IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** ppEnum) override;
    };
}
💡 記得!主選單一定要設定一組 UUID 供延伸模組做識別。
在子選單中,只需要實作「Invoke」。這裡為了方便展示,執行一個簡單的 cmd 命令:
#include "BaseExplorerCommand.h"

namespace Win11ContextMenuDemo::ExplorerCommand
{
    // Sub1ExplorerCommand 是一個實現子選單的類別,繼承自 BaseExplorerCommand。
    // Sub1ExplorerCommand is a class that implements a submenu, inheriting from BaseExplorerCommand.
    class Sub1ExplorerCommand : public BaseExplorerCommand
    {
    public:
        // 構造函數。
        // Constructor.
        Sub1ExplorerCommand();

        // 執行子選單的命令邏輯。
        // Execute the command logic of the submenu.
        IFACEMETHODIMP Invoke(IShellItemArray* psiItemArray, IBindCtx* pbc) noexcept override;
    };
}
IFACEMETHODIMP Sub1ExplorerCommand::Invoke(IShellItemArray* psiItemArray, IBindCtx* pbc) noexcept try
{
    UNREFERENCED_PARAMETER(psiItemArray);
    UNREFERENCED_PARAMETER(pbc);
    STARTUPINFO si;
    PROCESS_INFORMATION pi;
    ZeroMemory(&si, sizeof(si));
    si.cb = sizeof(si);
    ZeroMemory(&pi, sizeof(pi));
    wstring commandLine = L"cmd.exe /C echo Hello World by Sub1Win11ContextMenu. && pause;";
    if (!CreateProcessW(nullptr, (LPWSTR)commandLine.c_str(), nullptr, nullptr, false, 0, nullptr, nullptr, &si, &pi))
    {
        return S_OK;
    }

    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    return S_OK;
}
CATCH_RETURN();
💡 子選單點擊後會觸發 Invoke 方法。該方法簡單的執行一個 cmd 並顯示 Hello World by Sub1Win11ContextMenu 字串。



實作 IEnumExplorerCommand 儲存子菜單

完成 IExplorerCommand 接口的基礎實作之後,下一步是實作 IEnumExplorerCommand,它也是 Windows Shell 擴展的一部分,它允許開發者枚舉(列出)在檔案總管的右鍵選單中應該顯示的命令。這個接口對於管理多個自定義命令,特別是當你想在右鍵選單中添加多個選項時,變得尤為重要。
和 IExplorerCommand 一樣,需要實做一些必要的方法:
namespace Win11ContextMenuDemo::ExplorerCommand
{
    // EnumExplorerCommand 是一個類,用於實現 IEnumExplorerCommand 介面,用於枚舉 IExplorerCommand 物件。
    // EnumExplorerCommand is a class that implements the IEnumExplorerCommand interface for enumerating IExplorerCommand objects.
    class EnumExplorerCommand : public winrt::implements<EnumExplorerCommand, IEnumExplorerCommand>
    {
    public:
        // 構造函數,接收一個 IExplorerCommand 物件列表。
        // Constructor that takes a list of IExplorerCommand objects.
        EnumExplorerCommand(std::vector<winrt::com_ptr<IExplorerCommand>> commands);

        // Next 方法用於獲取序列中的下一個命令。
        // The Next method is used to retrieve the next command in the sequence.
        IFACEMETHODIMP Next(ULONG celt, __out_ecount_part(celt, *pceltFetched) IExplorerCommand** apUICommand, __out_opt ULONG* pceltFetched);

        // Skip 方法用於跳過序列中的一定數量的命令。
        // The Skip method is used to skip a certain number of commands in the sequence.
        IFACEMETHODIMP Skip(ULONG celt);

        // Reset 方法重置枚舉器到初始狀態。
        // The Reset method resets the enumerator to its initial state.
        IFACEMETHODIMP Reset();

        // Clone 方法用於創建此枚舉器的一個副本。
        // The Clone method is used to create a copy of this enumerator.
        IFACEMETHODIMP Clone(__deref_out IEnumExplorerCommand** ppenum);

    private:
        // 儲存一系列的 IExplorerCommand 物件。
        // Stores a series of IExplorerCommand objects.
        std::vector<winrt::com_ptr<IExplorerCommand>> sub_commands;

        // 當前枚舉器的索引。
        // The current index of the enumerator.
        size_t currentIndex = 0;
    };
}
完成了 IEnumExplorerCommand 接口的基本實作後,我們的下一步是在主選單類別的「EnumSubCommands」方法中創建子選單並將其加入到 IEnumExplorerCommand 中:
IFACEMETHODIMP MainExplorerCommand::EnumSubCommands(IEnumExplorerCommand** ppEnum)
{
    vector<winrt::com_ptr<IExplorerCommand>> subCommands;
    auto subCommand = winrt::make<Sub1ExplorerCommand>();
    subCommands.push_back(subCommand.as<IExplorerCommand>());
    auto enumCommands = winrt::make_self<EnumExplorerCommand>(move(subCommands));
    *ppEnum = enumCommands.detach();
    return S_OK;
}
為了讓系統識別主選單包含子選單,我們需要適當地調整主選單類別中的「GetFlags」方法。當使用者互動時,系統才能正確地顯示子選單。可以通過返回「ECF_HASSUBCOMMANDS」的標誌值來實現:
IFACEMETHODIMP MainExplorerCommand::GetFlags(EXPCMDFLAGS* flags)
{
    *flags = ECF_HASSUBCOMMANDS;
    return S_OK;
}
以上就是自定義右鍵選單的主選單和子選單的建置方法。篇幅的關係,部分的程式碼實作就請到我的 Github 上參考囉。



註冊選單和安裝 Sparse package

完成右鍵選單的建置之後,為了讓這些自定義功能能夠在 Windows 11 中順利運作,我們還需要透過程式碼來註冊選單和安裝 Sparse Package。
  • 註冊 Windows 舊右鍵選單:雖然我們的主要目標是實現 Windows 11 的新右鍵選單,但為了向後兼容,我們也需要考慮在舊版本的 Windows 中如何展示我們的自定義選單。
  • 安裝 Sparse Package 以支援 Windows 11 的新選單:目前我知道在 Windows 11 中添加自定義選單,必須透過 Sparse Package 來實現,因此如果是 Windows 11 系統就必須多執行這一步。
基於以上原因,我實作了兩個函數:
// 定義 InstallContextMenu 方法
HRESULT Win11ContextMenuDemo::InstallContextMenu::InstallContextMenu()
{
    HRESULT result;

    // 檢查是否為 Windows 11 系統
    if (Win11ContextMenuDemo::Windows11Checker::IsWindows11())
    {
        // 如果是 Windows 11,則先註銷之前的右鍵選單
        UnRegisterContextMenu();

        // 創建一個新的線程來執行 Sparse Package 安裝程序
thread registerSparsePackageProgramThread(RegisterSparsePackageProgram); // 等待剛剛創建的線程結束 registerSparsePackageProgramThread.join(); } // 註冊右鍵選單 result = RegisterContextMenu(); // 通知系統關聯設置發生了變化 SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, 0, 0); return S_OK; } // 定義 UnInstallContextMenu 方法 HRESULT Win11ContextMenuDemo::InstallContextMenu::UnInstallContextMenu() { HRESULT result; // 嘗試註銷右鍵選單 result = UnRegisterContextMenu(); // 檢查註銷操作是否成功 if (result != S_OK) { // 如果注銷失敗,則返回錯誤代碼 return result; } // 檢查是否為 Windows 11 系統 if (Win11ContextMenuDemo::Windows11Checker::IsWindows11()) { // 如果是 Windows 11,則創建一個新的線程來執行 Sparse Package 解除安裝程序 thread unRegisterSparsePackageThread(UnRegisterSparsePackageProgram); // 等待線程結束 unRegisterSparsePackageThread.join(); } // 通知系統關聯設置發生了變化 SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, 0, 0); return S_OK; }
然後我們只需要確保「InstallContextMenu」「UnInstallContextMenu」能夠在註冊 DLL 和註銷 DLL 時正確執行,即可在程式碼中完成選單的安裝和解除安裝。
💡 相關的 InstallContextMenu 和 UnInstallContextMenu 的實作,可以參考 Github 程式碼。



建置 DLLMain

最後為了讓 Windows Shell 能夠順利地獲取並使用我們的自定義右鍵選單,我們需要建置 DLLMain 函數並實作幾個關鍵的對外接口。這些接口是 Windows Shell 用來與 DLL 互動的橋樑,對於確保我們自定義功能的正常。以下為 DLLMain 的程式碼:
// 全域變數,用來儲存 DLL 模組的句柄。
// Global variable to store the handle of the DLL module.
HMODULE g_module; 

// DLL 的主入口函數。
// Main entry point function for the DLL.
BOOL APIENTRY DllMain(HMODULE hModule,
    DWORD  ul_reason_for_call,
    LPVOID lpReserved
)
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        // 當 DLL 被載入到程序時執行。
        // Executed when the DLL is loaded into a process.
        g_module = hModule;
        break;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

// 註冊 DLL 以便用於上下文選單擴展。
// Register the DLL for context menu extension.
STDAPI DllRegisterServer()
{
    return Win11ContextMenuDemo::InstallContextMenu::InstallContextMenu();
}

// 注銷 DLL。
// Unregister the DLL.
STDAPI DllUnregisterServer()
{
    return Win11ContextMenuDemo::InstallContextMenu::UnInstallContextMenu();
}

// 返回一個類工廠以創建對象的實例。
// Returns a class factory to create an object's instance.
_Use_decl_annotations_ STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID* ppv) try
{
    *ppv = nullptr;

    if (rclsid == __uuidof(Win11ContextMenuDemo::ExplorerCommand::MainExplorerCommand))
    {
        // 如果請求的是 MainExplorerCommand 的類工廠,則創建並返回。
        // Create and return the class factory if MainExplorerCommand is requested.
        return winrt::make<Win11ContextMenuDemo::ClassFactory::COMClassFactory<Win11ContextMenuDemo::ExplorerCommand::MainExplorerCommand>>().as(riid, ppv);
    }
    else
    {
        // 如果沒有可用的類,則返回錯誤。
        // Return an error if the class is not available.
        return CLASS_E_CLASSNOTAVAILABLE;
    }
}
catch (...)
{
    // 捕捉並處理任何異常。
    // Catch and handle any exceptions.
    return winrt::to_hresult();
}

// 檢查是否可以註銷 DLL。
// Check whether the DLL can be unloaded.
__control_entrypoint(DllExport) STDAPI DllCanUnloadNow()
{
    // 檢查是否有活動的對象或類工廠。
    // Check for active objects or class factories.
    if (winrt::get_module_lock())
    {
        // 如果有,則不能註銷。
// Cannot unload if there are. return S_FALSE; } else { // 如果沒有,則可以註銷。
// Can unload if there are none. return S_OK; } }
  • DLLMain:是 DLL 的主要入口點。它在 DLL 被加載、註銷,或者執行緒創建和終止時被調用。
  • DllRegisterServer和DllUnregisterServer:用於註冊或註銷 DLL時執行。為了讓系統能夠識別並使用我們的自定義選單,需要在這兩個方法中分別呼叫剛才建立的「InstallContextMenu」「UnInstallContextMenu」進行選單的安裝和解除安裝。
  • DllGetClassObject:它負責返回一個用於創建 COM 元件的 IClassFactory 工廠對象。我們需要檢查傳入的參數是否符合我們主選單所設定的 UUID。如果檢查通過,則回傳一個 IClassFactory 對象,該對象為我們自定義的主選單。
    💡 IClassFactory 的主要作用是創建 COM 元件。在 COM 編程模型中,元件的創建通常是透過工廠對象來完成。
  • DllCanUnloadNow:用於確定 DLL 是否可以被安全註銷,確保在不再需要時,DLL 可以被正確且安全地註銷。



總結

以上就是使用 C++ 實現 IExplorerCommand 自定義選單的建置和開發過程。接下我們只需要編譯專案產生出 DLL 後,透過 Sparse Package 打包並使用 regsver32 註冊該 DLL 就可以完成 Windows 11 自定義的右鍵選單安裝,不過由於 Sparse Package 的實作和註冊 DLL 涉及多個步驟,我們將在下一篇文章中進行更詳細的介紹。



Demo 專案 Github 程式碼