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

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

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

2 Likes

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

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

약간의? 연구 끝에 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 Likes

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

1 Like

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

1 Like