방법을 찾았습니다. 수 차례의 시행착오를 겪으며 AI의 도움도 받아서 결국 해냈습니다. 인간승리라고나 할까요.
먼저 클래스 라이브러리 프로젝트를 만들어 줍니다. 반드시 닷넷 8 이상을 선택해 주세요.
프로젝트 파일을 다음과 같이 수정해줍니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<PublishAot>true</PublishAot>
<Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
</Project>
저는 Nullable
과 GlobalUsings
는 따로 만든 패키지에서 빌드 가져오기 파일로 추가했기 때문에 여기서는 없지만 여러분들은 추가하는 것을 권장합니다.
C# 소스 코드 파일을 다음 내용으로 바꿉니다.
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
namespace ComTest;
[Flags]
public enum Expcmdstate : uint {
Enabled = 0,
Disabled = 0x1,
Hidden = 0x2,
Checkbox = 0x4,
Checked = 0x8,
Radiocheck = 0x10
}
[Flags]
public enum Expcmdflags : uint {
Default = 0,
Hassubcommands = 0x1,
Hassplitbutton = 0x2,
Hidelabel = 0x4,
Isseparator = 0x8,
Hasluashield = 0x10,
Separatorbefore = 0x20,
Separatorafter = 0x40,
Isdropdown = 0x80,
Toggleable = 0x100,
Automenuicons = 0x200
}
[GeneratedComInterface]
[Guid("a08ce4d0-fa25-44ab-b57c-c7b1c323e0b9")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public unsafe partial interface IExplorerCommand {
[return: MarshalAs(UnmanagedType.LPWStr)]
string? GetTitle(void* itemArray);
[return: MarshalAs(UnmanagedType.LPWStr)]
string? GetIcon(void* itemArray);
[return: MarshalAs(UnmanagedType.LPWStr)]
string? GetToolTip(void* itemArray);
Guid GetCanonicalName();
Expcmdstate GetState(void* itemArray, [MarshalAs(UnmanagedType.Bool)] bool okToBeSlow);
void Invoke(void* itemArray, void* bc);
Expcmdflags GetFlags();
void** EnumSubCommands();
}
[GeneratedComClass]
[Guid("059F8A2D-271B-415E-9267-18B9E4B164DC")]
public sealed unsafe partial class TheCommand : IExplorerCommand {
public string? GetTitle(void* itemArray) => "TestCommand";
public string? GetIcon(void* itemArray) => throw new NotImplementedException();
public string? GetToolTip(void* itemArray) => throw new NotImplementedException();
public Guid GetCanonicalName() => throw new NotImplementedException();
public Expcmdstate GetState(void* itemArray, bool okToBeSlow) => Expcmdstate.Enabled;
public void Invoke(void* itemArray, void* bc) => MessageBoxW(IntPtr.Zero, "Hello, World!", string.Empty, 64);
public Expcmdflags GetFlags() => Expcmdflags.Default;
public void** EnumSubCommands() => throw new NotImplementedException();
[DllImport("user32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
private static extern int MessageBoxW(IntPtr hwnd, [MarshalAs(UnmanagedType.LPWStr)] string text, [MarshalAs(UnmanagedType.LPWStr)] string caption, uint type);
}
[GeneratedComInterface]
[Guid("00000001-0000-0000-C000-000000000046")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public unsafe partial interface IClassFactory {
[PreserveSig]
int CreateInstance(void* pUnkOuter, Guid* riid, void** ppvObject);
[PreserveSig]
int LockServer([MarshalAs(UnmanagedType.Bool)] bool fLock);
}
[GeneratedComClass]
[Guid("3B545406-898A-4FC7-8D87-A9E84D72CFBC")]
public unsafe partial class ClassFactory : IClassFactory {
private static readonly Guid IecIid = Guid.Parse("a08ce4d0-fa25-44ab-b57c-c7b1c323e0b9");
private static volatile int ServerLocks;
public static bool IsLocked => ServerLocks > 0;
public int CreateInstance(void* pUnkOuter, Guid* riid, void** ppvObject) {
if (*riid != IecIid) {
return unchecked((int)0x80040111); // CLASS_E_CLASSNOTAVAILABLE
}
StrategyBasedComWrappers sbcw = new();
TheCommand tc = new();
var ptr = sbcw.GetOrCreateComInterfaceForObject(tc, CreateComInterfaceFlags.None);
if (ptr == IntPtr.Zero) {
return unchecked((int)0x8000FFFF); // E_UNEXPECTED
}
try {
var hr = Marshal.QueryInterface(ptr, in *riid, out var pInterface);
if (hr != 0) {
return hr;
}
if (pInterface == IntPtr.Zero) {
return unchecked((int)0x8000FFFF); // E_UNEXPECTED
}
*ppvObject = pInterface.ToPointer();
return 0; // S_OK
} finally {
Marshal.Release(ptr);
}
}
public int LockServer(bool fLock) {
if (fLock) {
Interlocked.Increment(ref ServerLocks);
} else {
Interlocked.Decrement(ref ServerLocks);
}
return 0;
}
}
위 내용을 요약하자면 IExplorerCommand
와 그 인터페이스를 구현하는 클래스, IClassFactory
와 그 인터페이스를 구현하는 클래스를 정의한 것입니다.
void 포인터를 남발(…)한 것을 알 수 있는데 대부분은 IShellItemArray
에 대한 포인터입니다. 왜 이렇게 만들었냐면은 그냥 귀찮아서… 이렇게 때워도 정상 작동합니다.
자, 이제 핵심입니다. 이제 우리는 DllGetClassObject
와 DllCanUnloadNow
를 직접 구현해 줄 것입니다. 비밀은 네이티브 AOT와 UnmanagedCallersOnly
에 있습니다.
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
namespace ComTest;
public static unsafe class Dll {
private static readonly Guid IID_IClassFactory = Guid.Parse("00000001-0000-0000-C000-000000000046");
[UnmanagedCallersOnly(EntryPoint = nameof(DllGetClassObject))]
public static int DllGetClassObject(Guid* clsid, Guid* iid, void** ppv) {
*ppv = null;
if (*iid != IID_IClassFactory) {
return unchecked((int)0x80040111); // CLASS_E_CLASSNOTAVAILABLE
}
StrategyBasedComWrappers sbcw = new();
ClassFactory cf = new();
var ptr = sbcw.GetOrCreateComInterfaceForObject(cf, CreateComInterfaceFlags.None);
if (ptr == IntPtr.Zero) {
return unchecked((int)0x8000FFFF); // E_UNEXPECTED
}
try {
var hr = Marshal.QueryInterface(ptr, in *iid, out var pInterface);
if (hr != 0) {
return hr;
}
if (pInterface == IntPtr.Zero) {
return unchecked((int)0x8000FFFF); // E_UNEXPECTED
}
*ppv = pInterface.ToPointer();
return 0; // S_OK
} finally {
Marshal.Release(ptr);
}
}
[UnmanagedCallersOnly(EntryPoint = nameof(DllCanUnloadNow))]
public static int DllCanUnloadNow() => ClassFactory.IsLocked.GetHashCode();
}
이제 dotnet publish
를 해줍니다. native와 publish 폴더에는 1~2메가 가량의 dll이 있을 것입니다. 이게 바로 네이티브 DLL입니다.
이제 텍스트 편집기를 열고 다음 내용을 reg1.reg
로 저장합니다.
Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\*\shell\TheCommand]
"CommandStateSync"=""
"ExplorerCommandHandler"="{059F8A2D-271B-415E-9267-18B9E4B164DC}"
[HKEY_CLASSES_ROOT\CLSID\{059F8A2D-271B-415E-9267-18B9E4B164DC}]
@="TheCommand"
[HKEY_CLASSES_ROOT\CLSID\{059F8A2D-271B-415E-9267-18B9E4B164DC}\InprocServer32]
@="(경로)\\ComTest\\ComTest\\bin\\Release\\win-x64\\publish\\ComTest.dll"
"ThreadingModel"="Apartment"
당연히 (경로)
부분은 여러분의 프로젝트가 있는 곳으로 수정해줘야 합니다.
이제 파일 탐색기를 완전히 종료했다가 다시 시작해봅시다. 방법은 다들 아실거라고 믿습니다.
아무 파일에나 우클릭 - 추가 옵션 표시를 누르면 이렇게 TestCommand가 표시됩니다.
누르면 메시지 박스가 잘 뜨는 걸 확인할 수 있습니다.
이번에도 소스 코드 링크를 올릴려고 했는데… 위에 올린 소스 코드와 완전히 똑같네요. 그래서 굳이 올리진 않겠습니다.