From a4c64bafb9927098f16324b33c0add8f70dba327 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 8 Sep 2026 04:27:54 +0900 Subject: [PATCH] fix(shell): resolve context menu shift-click requirement on Windows 10 & 11 --- .../Everything2Everything.App.csproj | 23 ++ .../Shell/ContextMenuRegistrar.cs | 236 ++++++++++++++---- .../Views/MainWindow.xaml.cs | 2 +- .../ContextMenuRegistrarTests.cs | 40 +++ 4 files changed, 257 insertions(+), 44 deletions(-) diff --git a/src/Everything2Everything.App/Everything2Everything.App.csproj b/src/Everything2Everything.App/Everything2Everything.App.csproj index 4d141e9..0f660ef 100644 --- a/src/Everything2Everything.App/Everything2Everything.App.csproj +++ b/src/Everything2Everything.App/Everything2Everything.App.csproj @@ -35,6 +35,29 @@ + + + + PreserveNewest + PreserveNewest + + + Everything2Everything.Shell.dll + PreserveNewest + PreserveNewest + + + AppxManifest.xml + PreserveNewest + PreserveNewest + + + Assets\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + diff --git a/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs index 0b0820d..76da67f 100644 --- a/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs +++ b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs @@ -10,6 +10,69 @@ public static class ContextMenuRegistrar private const string MainLabel = "Everything2Everything으로 변환"; private const string SubmenuKeyPrefix = "Everything2Everything.SubMenu."; + public const string ClassicContextMenuOverrideKey = @"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32"; + + public sealed class ContextMenuVerbPlan + { + public string Extension { get; init; } = ""; + public string RootKeyPath { get; init; } = ""; + public string RootLabel { get; init; } = ""; + public string IconPath { get; init; } = ""; + public bool UsesExtendedSubCommands { get; init; } = true; // 의도적 true로 RED 유도 + public string? SubCommandsValue { get; init; } = null; + public IReadOnlyList Items { get; init; } = Array.Empty(); + } + + public sealed class ContextMenuItemPlan + { + public string SubKeyPath { get; init; } = ""; + public string VerbName { get; init; } = ""; + public string Label { get; init; } = ""; + public string IconPath { get; init; } = ""; + public string Command { get; init; } = ""; + } + + public static ContextMenuVerbPlan BuildVerbPlan(ConversionEngine engine, string ext, string exePath) + { + var availableOutputs = GetAvailableOutputs(engine, ext); + var icon = exePath + ",0"; + var rootKeyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}"; + + var items = new List(); + foreach (var (outExt, outLabel, sortPrefix) in availableOutputs) + { + var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}"; + var cliExt = outExt.TrimStart('.'); + items.Add(new ContextMenuItemPlan + { + SubKeyPath = $@"{rootKeyPath}\shell\{subVerbName}", + VerbName = subVerbName, + Label = outLabel, + IconPath = icon, + Command = $"\"{exePath}\" to {cliExt} \"%1\"" + }); + } + + items.Add(new ContextMenuItemPlan + { + SubKeyPath = $@"{rootKeyPath}\shell\98_dialog", + VerbName = "98_dialog", + Label = "변환… (옵션 선택)", + IconPath = icon, + Command = $"\"{exePath}\" dialog \"%1\"" + }); + + return new ContextMenuVerbPlan + { + Extension = ext, + RootKeyPath = rootKeyPath, + RootLabel = MainLabel, + IconPath = icon, + UsesExtendedSubCommands = false, // Shift 불필요: 마우스 일반 우클릭으로 즉시 펼쳐짐 + SubCommandsValue = "", + Items = items + }; + } private static readonly (string Ext, string Label, string SortPrefix)[] PopularOutputs = { @@ -44,16 +107,16 @@ public static class ContextMenuRegistrar public static void Register(ConversionEngine engine) { var exe = GetAppExecutablePath(); - var icon = exe + ",0"; foreach (var ext in CollectInputExtensions(engine)) { - var availableOutputs = GetAvailableOutputs(engine, ext); - if (availableOutputs.Count == 0) continue; + var plan = BuildVerbPlan(engine, ext, exe); + if (plan.Items.Count == 0) continue; - WriteCascade(ext, exe, icon, availableOutputs); + WriteCascade(plan, ext); } + TryRegisterWindows11SparsePackage(); NotifyShell(); } @@ -66,9 +129,44 @@ public static class ContextMenuRegistrar DeleteVerb(ext, "Everything2Everything.Dialog"); DeleteSubmenuTree(ext); } + + TryUnregisterWindows11SparsePackage(); NotifyShell(); } + public static bool IsClassicContextMenuEnabled() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(ClassicContextMenuOverrideKey); + return key != null; + } + catch + { + return false; + } + } + + public static void SetClassicContextMenuEnabled(bool enable) + { + try + { + if (enable) + { + using var key = Registry.CurrentUser.CreateSubKey(ClassicContextMenuOverrideKey, writable: true); + key?.SetValue(null, "", RegistryValueKind.String); + } + else + { + Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}", throwOnMissingSubKey: false); + } + NotifyShell(); + } + catch + { + } + } + private static IEnumerable CollectInputExtensions(ConversionEngine engine) { return engine.Providers.Implemented @@ -79,28 +177,25 @@ public static class ContextMenuRegistrar .Distinct(); } - private static void WriteCascade( - string ext, - string exe, - string icon, - IReadOnlyList<(string Ext, string Label, string SortPrefix)> availableOutputs) + private static void WriteCascade(ContextMenuVerbPlan plan, string ext) { - var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.'); - - var verbPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}"; - using (var verbKey = Registry.CurrentUser.CreateSubKey(verbPath, writable: true) - ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {verbPath}")) + // 1. 루트 키 등록 (Shift 없는 일반 우클릭에서 즉시 서브메뉴 노출) + using (var verbKey = Registry.CurrentUser.CreateSubKey(plan.RootKeyPath, writable: true) + ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {plan.RootKeyPath}")) { - verbKey.SetValue(null, MainLabel, RegistryValueKind.String); - verbKey.SetValue("MUIVerb", MainLabel, RegistryValueKind.String); - verbKey.SetValue("Icon", icon, RegistryValueKind.String); - verbKey.SetValue("SubCommands", "", RegistryValueKind.String); - verbKey.SetValue("ExtendedSubCommandsKey", submenuKeyName, RegistryValueKind.String); + verbKey.SetValue(null, plan.RootLabel, RegistryValueKind.String); + verbKey.SetValue("MUIVerb", plan.RootLabel, RegistryValueKind.String); + verbKey.SetValue("Icon", plan.IconPath, RegistryValueKind.String); + verbKey.SetValue("SubCommands", plan.SubCommandsValue ?? "", RegistryValueKind.String); + + // 구버전에서 Shift 키를 강제하던 ExtendedSubCommandsKey 제거 + try { verbKey.DeleteValue("ExtendedSubCommandsKey", throwOnMissingValue: false); } catch { } try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { } } - var submenuShellPath = $@"Software\Classes\{submenuKeyName}\shell"; - using (var existing = Registry.CurrentUser.OpenSubKey(submenuShellPath, writable: true)) + // 2. 루트 직하위 shell 키 정리 및 서브메뉴 항목 등록 + var shellRootPath = $@"{plan.RootKeyPath}\shell"; + using (var existing = Registry.CurrentUser.OpenSubKey(shellRootPath, writable: true)) { if (existing is not null) { @@ -111,31 +206,22 @@ public static class ContextMenuRegistrar } } - foreach (var (outExt, outLabel, sortPrefix) in availableOutputs) + foreach (var item in plan.Items) { - var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}"; - var cliExt = outExt.TrimStart('.'); - WriteSubmenuItem(submenuKeyName, subVerbName, outLabel, icon, - $"\"{exe}\" to {cliExt} \"%1\""); + using var key = Registry.CurrentUser.CreateSubKey(item.SubKeyPath, writable: true) + ?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {item.SubKeyPath}"); + + key.SetValue(null, item.Label, RegistryValueKind.String); + key.SetValue("MUIVerb", item.Label, RegistryValueKind.String); + key.SetValue("Icon", item.IconPath, RegistryValueKind.String); + + using var commandKey = key.CreateSubKey("command", writable: true) + ?? throw new InvalidOperationException("command 하위 키 생성 실패"); + commandKey.SetValue(null, item.Command, RegistryValueKind.String); } - WriteSubmenuItem(submenuKeyName, "98_dialog", "변환… (옵션 선택)", icon, - $"\"{exe}\" dialog \"%1\""); - } - - private static void WriteSubmenuItem(string submenuKeyName, string verbName, string label, string icon, string command) - { - var path = $@"Software\Classes\{submenuKeyName}\shell\{verbName}"; - using var key = Registry.CurrentUser.CreateSubKey(path, writable: true) - ?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {path}"); - - key.SetValue(null, label, RegistryValueKind.String); - key.SetValue("MUIVerb", label, RegistryValueKind.String); - key.SetValue("Icon", icon, RegistryValueKind.String); - - using var commandKey = key.CreateSubKey("command", writable: true) - ?? throw new InvalidOperationException("command 하위 키 생성 실패"); - commandKey.SetValue(null, command, RegistryValueKind.String); + // 3. 구버전 독립 SubMenu 키 정리 (이전 버전 찌꺼기 제거) + DeleteSubmenuTree(ext); } private static void DeleteVerb(string ext, string verb) @@ -172,6 +258,70 @@ public static class ContextMenuRegistrar + Path.DirectorySeparatorChar + "Everything2Everything.exe"; } + private static void TryRegisterWindows11SparsePackage() + { + try + { + if (Environment.OSVersion.Version.Build < 22000) return; + + var manifestPath = FindAppxManifestPath(); + if (string.IsNullOrEmpty(manifestPath) || !File.Exists(manifestPath)) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"Add-AppxPackage -Register '{manifestPath}'\"", + CreateNoWindow = true, + UseShellExecute = false + }; + using var proc = System.Diagnostics.Process.Start(psi); + proc?.WaitForExit(5000); + } + catch + { + // Sparse Package 등록 실패(서명/정책 등) 시에도 레지스트리 캐스케이드는 정상이므로 무시 + } + } + + private static void TryUnregisterWindows11SparsePackage() + { + try + { + if (Environment.OSVersion.Version.Build < 22000) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -NonInteractive -WindowStyle Hidden -Command \"Get-AppxPackage -Name '*Everything2Everything*' | Remove-AppxPackage\"", + CreateNoWindow = true, + UseShellExecute = false + }; + using var proc = System.Diagnostics.Process.Start(psi); + proc?.WaitForExit(5000); + } + catch + { + } + } + + public static string? FindAppxManifestPath() + { + var baseDir = AppContext.BaseDirectory; + var candidates = new[] + { + Path.Combine(baseDir, "AppxManifest.xml"), + Path.Combine(baseDir, "Package.appxmanifest"), + Path.Combine(baseDir, "..", "packaging", "Package.appxmanifest"), + Path.Combine(baseDir, "..", "..", "packaging", "Package.appxmanifest") + }; + + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) return Path.GetFullPath(candidate); + } + return null; + } + private static void NotifyShell() { try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); } diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index 0805fde..f9c3468 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -1213,7 +1213,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC { ContextMenuRegistrar.Register(_engine); MessageBox.Show(this, - "컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".", + "컨텍스트 메뉴를 성공적으로 등록했습니다.\n파일 우클릭 시 \"Everything2Everything으로 변환\" 메뉴가 즉시 표시됩니다.", "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information); } catch (Exception ex) diff --git a/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs b/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs index 136a53a..ca8c9ee 100644 --- a/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs +++ b/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs @@ -56,4 +56,44 @@ public class ContextMenuRegistrarTests Assert.DoesNotContain(outputs, o => o.Ext == ".gif"); Assert.DoesNotContain(outputs, o => o.Ext == ".mp4"); } + + [Fact] + public void BuildVerbPlan_Png_DoesNotUseExtendedSubCommands_ForNormalRightClick() + { + // Windows 10/11에서 Shift 키 없이 일반 마우스 우클릭만으로 컨텍스트 메뉴가 바로 노출되어야 한다. + // ExtendedSubCommandsKey가 사용되면 Shift를 누를 때만 서브메뉴가 나오는 심각한 버그가 발생한다. + var engine = CreateEngine(); + var plan = ContextMenuRegistrar.BuildVerbPlan(engine, ".png", @"C:\Apps\E2E\Everything2Everything.exe"); + + Assert.False(plan.UsesExtendedSubCommands, "일반 우클릭에서 바로 노출되려면 UsesExtendedSubCommands가 반드시 false여야 합니다."); + Assert.Equal("", plan.SubCommandsValue); + } + + [Fact] + public void BuildVerbPlan_Png_PlacesSubVerbsUnderRootShellKey() + { + // Windows 7+ 표준 정적 캐스케이드 서브메뉴는 루트 동사 키 직하위의 \shell\ 키에 배치되어야 한다. + var engine = CreateEngine(); + var plan = ContextMenuRegistrar.BuildVerbPlan(engine, ".png", @"C:\Apps\E2E\Everything2Everything.exe"); + + Assert.NotEmpty(plan.Items); + var expectedPrefix = @"Software\Classes\SystemFileAssociations\.png\shell\Everything2Everything\shell\"; + foreach (var item in plan.Items) + { + Assert.StartsWith(expectedPrefix, item.SubKeyPath); + } + + // 대화상자 선택 메뉴(98_dialog)가 포함되어 있는지 검증 + var dialogItem = plan.Items.FirstOrDefault(i => i.VerbName.EndsWith("dialog")); + Assert.NotNull(dialogItem); + Assert.Contains("dialog", dialogItem.Command); + } + + [Fact] + public void ClassicContextMenuOverrideKey_Matches_Windows11_Specification() + { + // Windows 11에서 모던 메뉴 대신 항상 1차 클래식 메뉴를 복원하는 레지스트리 키 검증 + Assert.Equal(@"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32", + ContextMenuRegistrar.ClassicContextMenuOverrideKey); + } }