1
0
Fork 0

fix(shell): resolve context menu shift-click requirement on Windows 10 & 11

This commit is contained in:
Yun Chan 2026-09-08 04:27:54 +09:00
parent 7e848a1a3f
commit a4c64bafb9
4 changed files with 257 additions and 44 deletions

View file

@ -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<ContextMenuItemPlan> Items { get; init; } = Array.Empty<ContextMenuItemPlan>();
}
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<ContextMenuItemPlan>();
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<string> 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); }