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);
+ }
}