C#과 닷넷(8+)로 윈도우 셸 확장을 만드는 방법

질문은 아니고, 그냥 호기심이 생겨서 그렇습니다.

제가 위 글에서 한번 시도해봤었는데 안되더라고요. 이유를 살펴보니 진짜 네이티브(C++)로 작성된 셸 확장은 DllGetClassObject였나 그런 함수가 있었는데 Native AOT로 만들어진 DLL을 뜯어보니 그런 함수가 아예 없었는지 아니면 있는데 COM 클래스들을 노출시키는? 처리를 안 해주는지는 기억이 잘 나지는 않지만 어쨌든 제가 만든 C# COM 클래스는 처리를 안 해주던군요. 만약 그런 과정을 해준다면 순수 C#으로 셸 확장 개발도 가능하지 않을까 싶습니다.

2개의 좋아요

방법을 찾았습니다. 수 차례의 시행착오를 겪으며 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>

저는 NullableGlobalUsings는 따로 만든 패키지에서 빌드 가져오기 파일로 추가했기 때문에 여기서는 없지만 여러분들은 추가하는 것을 권장합니다.

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에 대한 포인터입니다. 왜 이렇게 만들었냐면은 그냥 귀찮아서… 이렇게 때워도 정상 작동합니다.

자, 이제 핵심입니다. 이제 우리는 DllGetClassObjectDllCanUnloadNow를 직접 구현해 줄 것입니다. 비밀은 네이티브 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가 표시됩니다.

누르면 메시지 박스가 잘 뜨는 걸 확인할 수 있습니다.

이번에도 소스 코드 링크를 올릴려고 했는데… 위에 올린 소스 코드와 완전히 똑같네요. 그래서 굳이 올리진 않겠습니다.

15개의 좋아요

좋네요! 도움이 많이 될 것 같습니다! 공유 감사드립니다.

약간의? 연구 끝에 COM 객체 생성을 자동화하는 라이브러리를 하나 만들었습니다.

사용법도 간단합니다.

using Bluehill.NativeCom;

[GeneratedComInterface]
[Guid("a08ce4d0-fa25-44ab-b57c-c7b1c323e0b9")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public partial interface IExplorerCommand { ... }

[GeneratedComClass]
[Guid("GUID")]
public partial class Implementation : IExplorerCommand { ... }

[GeneratedComClass]
[ClassFactory(typeof(Implementation))]
internal partial class MyClassFactory : IClassFactory;

이렇게만 해주면 소스 생성기가 자동으로 팩토리 내부는 물론 DllGetClassObjectDllCanUnloadNow 함수까지도 자동으로 만들어 줍니다. 당연하겠지만 사용할 COM 인터페이스(위 예시에서는 IExplorerCommand)는 직접 C#으로 옮기셔야 합니다.

여담입니다만 XML 문서 작성하는게 귀찮아서 ReSharper의 AI Assistant를 사용해서 작성했습니다. 괜찮더라고요.

2개의 좋아요

이렇게 해도 윈11에서 [추가 옵션 표시] 전 단계에서 커맨드를 노출시킬수는 없는 것이지요?

1개의 좋아요

윈도우 11 우클릭 메뉴에 커맨드를 노출시키려면 dll을 msix로 패키징해야 한다고 알고 있어요.

3개의 좋아요

어찌저찌하여 원래 목표였던 윈도우 11 우클릭 메뉴에 항목을 추가하는 데에 성공했습니다.

이 과정에서 알게 된 사실은 윈도우 11 우클릭 메뉴는 탐색기가 띄우는 게 아닌 dllhost.exe가 띄운다는 것…

소스 코드는 아래 링크에 있습니다.

6개의 좋아요