Pull to refresh

Разработка Shell Extensions для Windows Explorer

Reading time 9 min
Views 20K
Для повышения удобства разрабатываемых продуктов, мы стараемся обеспечить максимальный уровень интеграции функционала в операционную систему, чтобы пользователю было удобно использовать весь потенциал приложения. В этой статье будут рассмотрены теоретические и практические аспекты разработки Shell Extensions, компонентов позволяющих интегрироваться в оболочку операционной системы Windows. В качестве примера рассмотрим расширение списка контекстного меню для файлов, а так же проведем обзор уже существующих решений в этой области.


Вообще говоря, существует огромное множество вариантов интеграционных компонентов оболочки операционной системы Windows, например: апплеты панели управления, хранители экрана и прочее, однако в данной статье мне бы хотелось подробнее остановиться на возможностях расширения Windows Explorer, компонента ОС, который мне приходилось расширять больше остальных, ввиду его функциональной нагрузки.

Windows Explorer позволяет расширять себя посредством использования специализированных COM объектов. Windows API, содержит интерфейсы и структуры описывающие, как должны работать такие COM объекты, какие методы должны экспортировать. После того как COM объект реализующий нужный функционал разработан, он регистрируется по определенному пути в реестре Windows, так чтобы Windows Explorer при выполнении описанного при регистрации функционала обратился к соответствующему COM объекту.
Итак, начнем разработку COM объекта позволяющего расширить список контекстного меню для файлов.
Разрабатывать будем при помощи .net framework.

Добавим в Assembly.cs следующие директивы, позволяющие использовать нашу сборку как COM объект:
// Setting ComVisible to false makes the types in this assembly not visible 
// to COM components.  If you need to access a type in this assembly from 
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(true)]

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("345F4DC2-A9BF-11E2-AA47-CC986188709B")]


Нам понадобиться импортировать некоторые функции Windows API.
		[DllImport("shell32")]
		internal static extern uint DragQueryFile(uint hDrop,uint iFile, StringBuilder buffer, int cch);

		[DllImport("user32")]
		internal static extern uint CreatePopupMenu();

		[DllImport("user32")]
		internal static extern int InsertMenuItem(uint hmenu, uint uposition, uint uflags, ref MENUITEMINFO mii);

        [DllImport("user32.dll")]
        internal static extern bool SetMenuItemBitmaps(IntPtr hMenu, uint uPosition,
           uint uFlags, IntPtr hBitmapUnchecked, IntPtr hBitmapChecked);

        [DllImport("Shell32.dll")]
        internal static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
        const int SHCNE_ASSOCCHANGED = 0x08000000;

        [DllImport("user32.dll", SetLastError = true)]
        internal static extern bool PostMessage(IntPtr hWnd, [MarshalAs(UnmanagedType.U4)] uint Msg, IntPtr wParam, IntPtr lParam);

        [DllImport("user32.dll", SetLastError = true)]
        internal static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

Функция DragQueryFile позволит нам получить список файлов выбранных в каталоге, SHChangeNotify оповестить операционную систему о том, что оболочка была изменена.

Так как мы разрабатываем COM объект, который расширяет контекстное меню, мы должны реализовать интерфейс IShellExtInit. В методе Initialize мы получим базовую информацию о каталоге, в котором выполняемся.

	[ComImport(), InterfaceType(ComInterfaceType.InterfaceIsIUnknown), GuidAttribute("000214e8-0000-0000-c000-000000000046")]
	public interface IShellExtInit
	{
		[PreserveSig()]
		int Initialize (IntPtr pidlFolder, IntPtr lpdobj, uint /*HKEY*/ hKeyProgID);
	}

Также необходимо описать и реализовать COM интерфейс IContextMenu. Значение поля PreserveSig, равное true, инициирует непосредственное преобразование неуправляемых сигнатур со значениями HRESULT или retval, а значение false вызывает автоматическое преобразование значений HRESULT или retval в исключения. По умолчанию для поля PreserveSig используется значение true.
	[ComImport(), InterfaceType(ComInterfaceType.InterfaceIsIUnknown), GuidAttribute("000214e4-0000-0000-c000-000000000046")]
	public	interface IContextMenu
	{
		// IContextMenu methods
		[PreserveSig()]
		int	QueryContextMenu(uint hmenu, uint iMenu, int idCmdFirst, int idCmdLast, uint uFlags);
		[PreserveSig()]
		void	InvokeCommand (IntPtr pici);
		[PreserveSig()]
		 void GetCommandString(int idCmd, uint uFlags, int pwReserved, [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 4)] byte[] pszName, uint cchMax);
	}

Метод QueryContextMenu будет вызван при вызове контекстного меню, в нем нам будет нужно реализовать функционал по добавлению пункта меню, GetCommandString будет возвращать некоторые детали по данной команде, ее описание и прочее. InvokeCommand будет вызван при выборе пункта меню, который мы добавим.

Для COM объекта так же необходимо реализовать функции установки и удаления.
[System.Runtime.InteropServices.ComRegisterFunctionAttribute()]
        static void RegisterServer(System.Type type)
		{
			try
			{
                string approved = string.Empty;
                string contextMenu = string.Empty;
				RegistryKey root;
				RegistryKey rk;
				root = Registry.LocalMachine;
				rk = root.OpenSubKey(Resources.ApprovedReg, true);
                rk.SetValue(type.GUID.ToString("B"), Resources.Extension);
                approved = rk.ToString();
                rk.Flush();
				rk.Close();

				root = Registry.ClassesRoot;
				rk = root.CreateSubKey(Resources.ExShellReg);
                rk.Flush();
                rk.SetValue(null, type.GUID.ToString("B"));
                contextMenu = rk.ToString();
                rk.Flush();
				rk.Close();

                EventLog.WriteEntry("Application", "Example ShellExt Registration Complete.\r\n" + approved + "\r\n" + contextMenu, EventLogEntryType.Information);

                RestartExplorer();

            }
			catch(Exception e)
			{
                EventLog.WriteEntry("Application", "Example ShellExt Registration error.\r\n" + e.ToString(), EventLogEntryType.Error);
            }
        }

В этой функции мы выполняем регистрацию нашего компонента в реестре, так как у нас компонент расширяющий функционал контекстного меню, мы регистрируемся в разделе ContextMenuHandlers (*\\shellex\\ContextMenuHandlers\\ExShell). После регистрации перезапускаем процесс explorer.exe для того, чтобы наши изменения сразу вступили в силу.
[System.Runtime.InteropServices.ComUnregisterFunctionAttribute()]
        static void UnregisterServer(System.Type type)
		{

			try
			{
                string approved = string.Empty;
                string contextMenu = string.Empty;
				RegistryKey root;
				RegistryKey rk;

				// Remove ShellExtenstions registration
				root = Registry.LocalMachine;
                rk = root.OpenSubKey(Resources.ApprovedReg, true);
                approved = rk.ToString();
                rk.DeleteValue(type.GUID.ToString("B"));
				rk.Close();

				// Delete  regkey
				root = Registry.ClassesRoot;
                contextMenu = Resources.ExShellReg;
                root.DeleteSubKey(Resources.ExShellReg);
                EventLog.WriteEntry("Application", "Example ShellExt Unregister Complete.\r\n" + approved + "\r\n" + contextMenu, EventLogEntryType.Information);

                Helpers.SHChangeNotify(0x08000000, 0, IntPtr.Zero, IntPtr.Zero);
            }
			catch(Exception e)
			{
                EventLog.WriteEntry("Application", "Example ShellExt Unregister error.\r\n" + e.ToString(), EventLogEntryType.Error);
            }
        }

Функция удаления компонента, очищаем реестр от созданных ранее ключей.
Далее переходим к процессу реализации интерфейсных функций.Реализуем интерфейсы IShellExtInit, IContextMenu. Не буду детально описывать весь код этого класса, остановлюсь на реализации функций данных интерфейсов.

		int	IShellExtInit.Initialize (IntPtr pidlFolder, IntPtr lpdobj, uint hKeyProgID)
		{
			try
			{
				if (lpdobj != (IntPtr)0)
				{
					// Get info about the directory
					IDataObject dataObject = (IDataObject)Marshal.GetObjectForIUnknown(lpdobj);
					FORMATETC fmt = new FORMATETC();
					fmt.cfFormat = CLIPFORMAT.CF_HDROP;
					fmt.ptd		 = 0;
					fmt.dwAspect = DVASPECT.DVASPECT_CONTENT;
					fmt.lindex	 = -1;
					fmt.tymed	 = TYMED.TYMED_HGLOBAL;
					STGMEDIUM medium = new STGMEDIUM();
					dataObject.GetData(ref fmt, ref medium);
					m_hDrop = medium.hGlobal;
				}
			}
			catch(Exception)
			{
			}
			return 0;
		}

Функция инициализации компонента, запускается при открытии каталога или любого другого объекта, в контексте которого может находиться контекстное меню. Используем интерфейс IDataObject для получения данных о текущем объекте, в частности нас интересует hGlobal. Этот Handle идентифицирует текущий объект, внутри которого и происходит наше выполнение.

Далее рассмотрим функцию, которая вызывается при выпадении контекстного меню.

int	IContextMenu.QueryContextMenu(uint hMenu, uint iMenu, int idCmdFirst, int idCmdLast, uint uFlags)
		{
			if ( (uFlags & 0xf) == 0 || (uFlags & (uint)CMF.CMF_EXPLORE) != 0)
			{
				uint nselected = Helpers.DragQueryFile(m_hDrop, 0xffffffff, null, 0);
                if (nselected > 0)
                {
                    for (uint i = 0; i < nselected; i++)
                    {
                        StringBuilder sb = new StringBuilder(1024);
                        Helpers.DragQueryFile(m_hDrop, i, sb, sb.Capacity + 1);
                        fileNames.Add(sb.ToString());
                    }
                }
                else
                    return 0;

В этом участке кода проверяем, что выполняемся в нужном контексте и пытаемся запросить количество выбранных в каталоге файлов, а так же сохранить список этих файлов. Также замечу, что при передаче iFile = 0xffffffff в функцию DragQueryFile она вернет количество файлов в каталоге.

// Add the popup to the context menu
								MENUITEMINFO mii = new MENUITEMINFO();
				mii.cbSize = 48;
				mii.fMask = (uint) MIIM.ID | (uint)MIIM.TYPE | (uint) MIIM.STATE;
                mii.wID = idCmdFirst;
				mii.fType = (uint) MF.STRING;
                mii.dwTypeData = Resources.MenuItem;
				mii.fState = (uint) MF.ENABLED;
                Helpers.InsertMenuItem(hMenu, (uint)iMenu, (uint)MF.BYPOSITION | (uint)MF.STRING, ref mii);
                commands.Add(idCmdFirst);

                System.Reflection.Assembly myAssembly = System.Reflection.Assembly.GetExecutingAssembly();
                Stream myStream = myAssembly.GetManifestResourceStream(Resources.BitmapName);
                Bitmap image = new Bitmap(myStream);
                Color backColor = image.GetPixel(1, 1);
                image.MakeTransparent(backColor);

                Helpers.SetMenuItemBitmaps((IntPtr)hMenu, (uint)iMenu, (uint)MF.BYPOSITION, image.GetHbitmap(), image.GetHbitmap());

				// Add a separator
				MENUITEMINFO sep = new MENUITEMINFO();
				sep.cbSize = 48;
				sep.fMask = (uint )MIIM.TYPE;
				sep.fType = (uint) MF.SEPARATOR;
                Helpers.InsertMenuItem(hMenu, iMenu + 1, 1, ref sep);			
			}
			return 1;
		}

Здесь добавляем новый пункт меню при помощи вызова функции InsertMenuItem, затем подготавливаем и добавляем иконку к этому пункту меню, а так же разделительную линию для эстетической красоты. Структура MENUITEMINFO описывает наш пункт меню, а именно его тип (ftype), содержащиеся данные (dwTypeData), состояние (fState), идентификатор пункта меню (wID). Переменная hMenu идентифицирует текущее выпавшее меню, iMenu позиция по которой мы добавляемся. Для того, чтобы получить более полную информацию, можно обратиться в MSDN.

Далее рассмотрим функцию GetCommandString
        void IContextMenu.GetCommandString(int idCmd, uint uFlags, int pwReserved, byte[] pszName, uint cchMax)
		{
            string commandString = String.Empty;
			switch(uFlags)
			{
			case (uint)GCS.VERB:
                    commandString = "test";
				break;
            case (uint)GCS.HELPTEXTW:
                    commandString = "test";
				break;
			}

            var buf = Encoding.Unicode.GetBytes(commandString);
            int cch = Math.Min(buf.Length, pszName.Length - 1);

            if (cch > 0)
            {
                Array.Copy(buf, 0, pszName, 0, cch);
            }
            else
            {
                // null terminate the buffer
                pszName[0] = 0;
            }
		}

Данная функция возвращает независимое от языка описание команды, а так же краткую подсказку в виде helptext соответственно.

Ну и последняя функция, которая будет вызвана при выборе нашего пункта меню:
		void IContextMenu.InvokeCommand (IntPtr pici)
		{
			try
			{
                System.Windows.Forms.MessageBox.Show("Test code");
			}
			catch(Exception exe)
			{
                EventLog.WriteEntry("Application", exe.ToString());
			}
		}

Тут все достаточно прозрачно.

Так как наш COM объект выполняется в контексте Windows Explorer, то для того чтобы его отлаживать, нам нужно подключаться к процессу explorer.exe. Для регистрации и удаления компонента Вы можете использовать bat файлы, поставляемые с исходниками к данной статье. Для регистрации мы используем RegAsm.exe утилиту, позволяющую COM клиентам использовать .net класс будто бы это COM, а также GacUtil для того, чтобы поместить сборку в GAC. После регистрации процесс explorer.exe будет перезапущен.
Так же обращу Ваше внимание на утилиту позволяющую просмотреть, а при необходимости и отредактировать все установленные в системе расширения Windows Explorer. Утилита называется ShellExView, скачать можно на сайте производителя Nirsoft или в приложении к статье.

Вот так выглядит наш компонент в ShellExView:


Так он выглядит при раскрытом контекстном меню:


Итак, мы рассмотрели пример разработки компонента расширяющего Windows Explorer, но данный вид расширения далеко не единственное, что мы можем изменять, и на что можем влиять.
Имея понимание того, как функционируют такого рода компоненты можно взглянуть на то, что уже было разработано сообществом и может быть использовано для облегчения выполнения таких операций.
Например, библиотека SharpShell позволяющая выполнять достаточно большой объем модификаций, а так же хорошо описанная в цикле статей .NET Shell Extensions на CodeProject. В качестве аналога также можно использовать библиотеку Windows Shell Framework или библиотеку для связки ATL + С++ Mini Shell Extension Framework.

Также обращу Ваше внимание на предупреждение Microsoft относительно разработки таких вот расширений: “Не рекомендуется писать расширения оболочки в языках .NET, так как используется только одна среда выполнения CLR для одного процесса, поэтому может возникнуть конфликт между двумя расширениями оболочки, использующими разные версии CLR. Однако .Net Framework 4 поддерживает технологию side-by-side для версий .Net Framework 2.0, 3.0, 3.5 и позволяет в одном и том же процессе использовать как старую CLR 2, так и новую CLR 4”.
Подробнее почитать про ограничения использования .net при разработке Shell Extensions можно здесь: Guidance for Implementing In-Process Extensions

Еще посмотреть:
Explorer column handler shell extension in C#
Creating Shell Extension Handlers


Исходники и утилиты: ExampleShell.rar

P.S Расширение не тестировал на Windows 8, судя по отзывам, для корректной работы в реестре нужно установить в разделе HKEY_CLASSES_ROOT\CLSID\{guid компонента}\InprocServer32 следующее значение ThreadingModel = Apartment.

Спасибо за внимание!
Tags:
Hubs:
+15
Comments 8
Comments Comments 8

Articles