From ac7f62feedff025b8e4138a6d756747cf3584f04 Mon Sep 17 00:00:00 2001 From: Haerbin23456 <60066765+Haerbin23456@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:15:45 +0800 Subject: [PATCH 01/73] chore(wip): checkpoint pre-refactor audit changes --- .github/workflows/build.yml | 3 + ContextMenuProfiler.Hook/src/ipc_server.cpp | 79 ++++++- .../ContextMenuProfiler.QualityChecks.csproj | 12 + ContextMenuProfiler.QualityChecks/Program.cs | 40 ++++ ContextMenuProfiler.UI/AssemblyInfo.cs | 2 + .../Core/BenchmarkService.cs | 212 ++++++++++-------- ContextMenuProfiler.UI/Core/HookIpcClient.cs | 70 ++++-- .../Core/RegistryScanner.cs | 68 ++++++ ContextMenuProfiler.sln | 14 ++ docs/CODE_QUALITY_AUDIT.md | 139 ++++++++++++ 10 files changed, 516 insertions(+), 123 deletions(-) create mode 100644 ContextMenuProfiler.QualityChecks/ContextMenuProfiler.QualityChecks.csproj create mode 100644 ContextMenuProfiler.QualityChecks/Program.cs create mode 100644 docs/CODE_QUALITY_AUDIT.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78c54a5..c0fb0a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,9 @@ jobs: - name: Build Solution run: dotnet build ContextMenuProfiler.sln -c Release + - name: Run Quality Checks + run: dotnet run --project ContextMenuProfiler.QualityChecks\ContextMenuProfiler.QualityChecks.csproj -c Release --no-build + - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: diff --git a/ContextMenuProfiler.Hook/src/ipc_server.cpp b/ContextMenuProfiler.Hook/src/ipc_server.cpp index 50c3442..f669fe9 100644 --- a/ContextMenuProfiler.Hook/src/ipc_server.cpp +++ b/ContextMenuProfiler.Hook/src/ipc_server.cpp @@ -5,6 +5,7 @@ static const LONG kMaxConcurrentPipeClients = 4; static volatile LONG g_ActivePipeClients = 0; static const DWORD kWorkerTimeoutMs = 1800; +static const size_t kMaxRequestBytes = 16384; struct IpcWorkItem { char request[2048]; @@ -27,11 +28,24 @@ void DoIpcWorkInternal(const char* request, char* response, int maxLen) { std::string mode = "AUTO"; std::string clsidStr, pathStr, dllHintStr; + bool v1Protocol = false; - if (reqStr.substr(0, 4) == "COM|") { + if (reqStr.rfind("CMP1|", 0) == 0) { + v1Protocol = true; + reqStr = reqStr.substr(5); + size_t sep0 = reqStr.find('|'); + if (sep0 == std::string::npos) { + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_FORMAT\",\"error\":\"Missing Mode\"}"); + return; + } + mode = reqStr.substr(0, sep0); + reqStr = reqStr.substr(sep0 + 1); + } + + if (!v1Protocol && reqStr.substr(0, 4) == "COM|") { mode = "COM"; reqStr = reqStr.substr(4); - } else if (reqStr.substr(0, 5) == "ECMD|") { + } else if (!v1Protocol && reqStr.substr(0, 5) == "ECMD|") { mode = "ECMD"; reqStr = reqStr.substr(5); } @@ -39,7 +53,7 @@ void DoIpcWorkInternal(const char* request, char* response, int maxLen) { // Format: CLSID|Path[|DllHint] size_t sep1 = reqStr.find('|'); if (sep1 == std::string::npos) { - snprintf(response, maxLen, "{\"success\":false,\"error\":\"Format Error\"}"); + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_FORMAT\",\"error\":\"Format Error\"}"); return; } clsidStr = reqStr.substr(0, sep1); @@ -55,18 +69,27 @@ void DoIpcWorkInternal(const char* request, char* response, int maxLen) { CLSID clsid; wchar_t wClsid[64]; - MultiByteToWideChar(CP_UTF8, 0, clsidStr.c_str(), -1, wClsid, 64); + if (MultiByteToWideChar(CP_UTF8, 0, clsidStr.c_str(), -1, wClsid, 64) <= 0) { + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_CLSID_UTF8\",\"error\":\"Bad CLSID Encoding\"}"); + return; + } if (FAILED(CLSIDFromString(wClsid, &clsid))) { - snprintf(response, maxLen, "{\"success\":false,\"error\":\"Bad CLSID\"}"); + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_CLSID\",\"error\":\"Bad CLSID\"}"); return; } wchar_t wPath[MAX_PATH]; - MultiByteToWideChar(CP_UTF8, 0, pathStr.c_str(), -1, wPath, MAX_PATH); + if (MultiByteToWideChar(CP_UTF8, 0, pathStr.c_str(), -1, wPath, MAX_PATH) <= 0) { + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_PATH_UTF8\",\"error\":\"Bad Path Encoding\"}"); + return; + } wchar_t wDllHint[MAX_PATH] = { 0 }; if (!dllHintStr.empty()) { - MultiByteToWideChar(CP_UTF8, 0, dllHintStr.c_str(), -1, wDllHint, MAX_PATH); + if (MultiByteToWideChar(CP_UTF8, 0, dllHintStr.c_str(), -1, wDllHint, MAX_PATH) <= 0) { + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_DLLHINT_UTF8\",\"error\":\"Bad DllHint Encoding\"}"); + return; + } } // We'll pass the dllHint to the handlers @@ -110,14 +133,46 @@ DWORD WINAPI HandlePipeClientThread(LPVOID param) { HANDLE hPipe = (HANDLE)param; CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - char req[2048]; + std::string req; + req.reserve(2048); DWORD read = 0; DWORD written = 0; - if (ReadFile(hPipe, req, 2047, &read, NULL) && read > 0) { - req[read] = '\0'; + while (true) { + char chunk[2048]; + BOOL ok = ReadFile(hPipe, chunk, sizeof(chunk), &read, NULL); + if (ok && read > 0) { + req.append(chunk, read); + if (req.size() > kMaxRequestBytes) { + const char* errRes = "{\"success\":false,\"code\":\"E_REQ_TOO_LARGE\",\"error\":\"Request Too Large\"}"; + WriteFile(hPipe, errRes, (DWORD)strlen(errRes), &written, NULL); + FlushFileBuffers(hPipe); + req.clear(); + break; + } + break; + } + DWORD err = GetLastError(); + if (!ok && err == ERROR_MORE_DATA) { + if (read > 0) { + req.append(chunk, read); + } + if (req.size() > kMaxRequestBytes) { + const char* errRes = "{\"success\":false,\"code\":\"E_REQ_TOO_LARGE\",\"error\":\"Request Too Large\"}"; + WriteFile(hPipe, errRes, (DWORD)strlen(errRes), &written, NULL); + FlushFileBuffers(hPipe); + req.clear(); + break; + } + continue; + } + break; + } + + if (!req.empty()) { + req.push_back('\0'); IpcWorkItem* workItem = new IpcWorkItem(); - strncpy_s(workItem->request, sizeof(workItem->request), req, _TRUNCATE); + strncpy_s(workItem->request, sizeof(workItem->request), req.c_str(), _TRUNCATE); workItem->response[0] = '\0'; workItem->maxLen = 65535; workItem->releaseByWorker = 0; @@ -169,7 +224,7 @@ DWORD WINAPI PipeThread(LPVOID) { HANDLE hPipe = CreateNamedPipeA( "\\\\.\\pipe\\ContextMenuProfilerHook", PIPE_ACCESS_DUPLEX, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, PIPE_UNLIMITED_INSTANCES, 65536, 65536, 0, &sa); if (hPipe == INVALID_HANDLE_VALUE) { Sleep(100); continue; } diff --git a/ContextMenuProfiler.QualityChecks/ContextMenuProfiler.QualityChecks.csproj b/ContextMenuProfiler.QualityChecks/ContextMenuProfiler.QualityChecks.csproj new file mode 100644 index 0000000..affa17f --- /dev/null +++ b/ContextMenuProfiler.QualityChecks/ContextMenuProfiler.QualityChecks.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0-windows10.0.19041.0 + enable + enable + + + + + + diff --git a/ContextMenuProfiler.QualityChecks/Program.cs b/ContextMenuProfiler.QualityChecks/Program.cs new file mode 100644 index 0000000..6af0024 --- /dev/null +++ b/ContextMenuProfiler.QualityChecks/Program.cs @@ -0,0 +1,40 @@ +using ContextMenuProfiler.UI.Core; + +static void AssertEqual(string expected, string actual, string caseName) +{ + if (!string.Equals(expected, actual, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"{caseName} failed. expected='{expected}', actual='{actual}'"); + } +} + +static void AssertNull(string? value, string caseName) +{ + if (value != null) + { + throw new InvalidOperationException($"{caseName} failed. expected null, actual='{value}'"); + } +} + +string requestWithoutHint = HookIpcClient.BuildRequest("{00000000-0000-0000-0000-000000000000}", @"C:\Temp\a.txt", null); +AssertEqual("CMP1|AUTO|{00000000-0000-0000-0000-000000000000}|C:\\Temp\\a.txt", requestWithoutHint, "BuildRequestWithoutHint"); + +string requestWithHint = HookIpcClient.BuildRequest("{00000000-0000-0000-0000-000000000000}", @"C:\Temp\a.txt", @"C:\x\h.dll"); +AssertEqual("CMP1|AUTO|{00000000-0000-0000-0000-000000000000}|C:\\Temp\\a.txt|C:\\x\\h.dll", requestWithHint, "BuildRequestWithHint"); + +string? malformed = HookIpcClient.ExtractJsonEnvelope("ERR|NOT_JSON"); +AssertNull(malformed, "ExtractJsonMalformed"); + +string? wrapped = HookIpcClient.ExtractJsonEnvelope("noise{\"success\":true,\"state\":1}tail"); +AssertEqual("{\"success\":true,\"state\":1}", wrapped ?? "", "ExtractJsonWrapped"); + +Console.WriteLine("Quality checks passed."); + +if (string.Equals(Environment.GetEnvironmentVariable("CMP_LIVE_PROBE"), "1", StringComparison.Ordinal)) +{ + var service = new BenchmarkService(); + var results = await service.RunSystemBenchmarkAsync(ScanMode.Targeted); + var measured = results.Count(r => r.TotalTime > 0); + var fallback = results.Count(r => string.Equals(r.Status, "Registry Fallback", StringComparison.OrdinalIgnoreCase)); + Console.WriteLine($"Live probe: total={results.Count}, measured={measured}, fallback={fallback}"); +} diff --git a/ContextMenuProfiler.UI/AssemblyInfo.cs b/ContextMenuProfiler.UI/AssemblyInfo.cs index cc29e7f..891623b 100644 --- a/ContextMenuProfiler.UI/AssemblyInfo.cs +++ b/ContextMenuProfiler.UI/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Windows; +using System.Runtime.CompilerServices; [assembly:ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located @@ -8,3 +9,4 @@ //(used if a resource is not found in the page, // app, or any theme specific resource dictionaries) )] +[assembly: InternalsVisibleTo("ContextMenuProfiler.QualityChecks")] diff --git a/ContextMenuProfiler.UI/Core/BenchmarkService.cs b/ContextMenuProfiler.UI/Core/BenchmarkService.cs index ff792ef..794b1ea 100644 --- a/ContextMenuProfiler.UI/Core/BenchmarkService.cs +++ b/ContextMenuProfiler.UI/Core/BenchmarkService.cs @@ -69,119 +69,142 @@ public class BenchmarkService public List RunSystemBenchmark(ScanMode mode = ScanMode.Targeted) { - // Use Task.Run to avoid deadlocks on UI thread when waiting for async tasks return Task.Run(() => RunSystemBenchmarkAsync(mode)).GetAwaiter().GetResult(); } public async Task> RunSystemBenchmarkAsync(ScanMode mode = ScanMode.Targeted, IProgress? progress = null) + { + bool useFolderContext = mode == ScanMode.Full ? false : false; + using var fileContext = ShellTestContext.Create(useFolderContext); + var registryHandlers = RegistryScanner.ScanHandlers(mode); + var staticVerbs = RegistryScanner.ScanStaticVerbs(); + return await RunBenchmarkCoreAsync(registryHandlers, staticVerbs, null, fileContext.Path, progress); + } + + public List RunBenchmark(string targetPath) + { + return Task.Run(() => RunBenchmarkAsync(targetPath)).GetAwaiter().GetResult(); + } + + public async Task> RunBenchmarkAsync(string targetPath, IProgress? progress = null) + { + if (string.IsNullOrWhiteSpace(targetPath)) + { + return await RunSystemBenchmarkAsync(ScanMode.Targeted, progress); + } + + var registryHandlers = RegistryScanner.ScanHandlersForPath(targetPath); + var staticVerbs = RegistryScanner.ScanStaticVerbsForPath(targetPath); + return await RunBenchmarkCoreAsync(registryHandlers, staticVerbs, targetPath, targetPath, progress); + } + + private async Task> RunBenchmarkCoreAsync( + Dictionary> registryHandlers, + Dictionary> staticVerbs, + string? packageTargetPath, + string hookContextPath, + IProgress? progress) { var allResults = new ConcurrentBag(); var resultsMap = new ConcurrentDictionary(); var semaphore = new SemaphoreSlim(8); - - using (var fileContext = ShellTestContext.Create(false)) + + var comTasks = registryHandlers.Select(async clsidEntry => { - // 1. Scan All Registry Handlers (COM) - var registryHandlers = RegistryScanner.ScanHandlers(mode); - var comTasks = registryHandlers.Select(async clsidEntry => + await semaphore.WaitAsync(); + try { - await semaphore.WaitAsync(); - try + var clsid = clsidEntry.Key; + var handlerInfos = clsidEntry.Value; + + if (resultsMap.ContainsKey(clsid)) return; + var meta = QueryClsidMetadata(clsid); + var result = new BenchmarkResult { - var clsid = clsidEntry.Key; - var handlerInfos = clsidEntry.Value; - - if (resultsMap.ContainsKey(clsid)) return; - var meta = QueryClsidMetadata(clsid); - var result = new BenchmarkResult - { - Clsid = clsid, - Type = "COM", - RegistryEntries = handlerInfos.ToList(), - Name = meta.Name, - BinaryPath = meta.BinaryPath, - ThreadingModel = meta.ThreadingModel, - FriendlyName = meta.FriendlyName - }; - - if (string.IsNullOrEmpty(result.Name)) result.Name = $"Unknown ({clsid})"; - - resultsMap[clsid] = result; - result.Category = DetermineCategory(result.RegistryEntries.Select(e => e.Location)); - - await EnrichBenchmarkResultAsync(result, fileContext.Path); - - bool isBlocked = ExtensionManager.IsExtensionBlocked(clsid); - bool hasDisabledPath = result.RegistryEntries.Any(e => e.Location.Contains("[Disabled]")); - result.IsEnabled = !isBlocked && !hasDisabledPath; - result.LocationSummary = string.Join(", ", result.RegistryEntries.Select(e => e.Location).Distinct()); - - allResults.Add(result); - progress?.Report(result); - } - finally { semaphore.Release(); } - }); + Clsid = clsid, + Type = "COM", + RegistryEntries = handlerInfos.ToList(), + Name = meta.Name, + BinaryPath = meta.BinaryPath, + ThreadingModel = meta.ThreadingModel, + FriendlyName = meta.FriendlyName + }; - await Task.WhenAll(comTasks); + if (string.IsNullOrEmpty(result.Name)) result.Name = $"Unknown ({clsid})"; - // 2. Scan Static Verbs - var staticVerbs = RegistryScanner.ScanStaticVerbs(); - foreach (var verbEntry in staticVerbs) - { - string key = verbEntry.Key; - var paths = verbEntry.Value; - string name = key.Split('|')[0]; - string command = key.Split('|')[1]; + resultsMap[clsid] = result; + result.Category = DetermineCategory(result.RegistryEntries.Select(e => e.Location)); - var verbResult = new BenchmarkResult - { - Name = name, - Type = "Static", - Status = "Static (Not Measured)", - BinaryPath = ExtractExecutablePath(command), - RegistryEntries = paths.Select(p => new RegistryHandlerInfo { - Path = p, - Location = $"Registry (Shell) - {p.Split('\\')[0]}" - }).ToList(), - InterfaceType = "Static Verb", - DetailedStatus = "Static shell verbs do not go through Hook COM probing and are displayed as not measured.", - TotalTime = 0, - Category = "Static" - }; + await EnrichBenchmarkResultAsync(result, hookContextPath); - bool anyDisabled = paths.Any(p => p.Split('\\').Last().StartsWith("-")); - verbResult.IsEnabled = !anyDisabled; - verbResult.LocationSummary = string.Join(", ", verbResult.RegistryEntries.Select(e => e.Location).Distinct()); - verbResult.IconLocation = ResolveStaticVerbIcon(paths.First(), verbResult.BinaryPath); + bool isBlocked = ExtensionManager.IsExtensionBlocked(clsid); + bool hasDisabledPath = result.RegistryEntries.Any(e => e.Location.Contains("[Disabled]")); + result.IsEnabled = !isBlocked && !hasDisabledPath; + result.LocationSummary = string.Join(", ", result.RegistryEntries.Select(e => e.Location).Distinct()); - allResults.Add(verbResult); - progress?.Report(verbResult); + allResults.Add(result); + progress?.Report(result); } + finally { semaphore.Release(); } + }); - // 3. Scan UWP Extensions (Parallelized) - var uwpTasks = PackageScanner.ScanPackagedExtensions(null) - .Where(r => r.Clsid.HasValue && !resultsMap.ContainsKey(r.Clsid.Value)) - .Select(async uwpResult => - { - await semaphore.WaitAsync(); - try - { - uwpResult.Category = "UWP"; - await EnrichBenchmarkResultAsync(uwpResult, fileContext.Path); - - uwpResult.IsEnabled = !ExtensionManager.IsExtensionBlocked(uwpResult.Clsid!.Value); - uwpResult.LocationSummary = "Modern Shell (UWP)"; - - allResults.Add(uwpResult); - progress?.Report(uwpResult); - } - finally { semaphore.Release(); } - }); + await Task.WhenAll(comTasks); - await Task.WhenAll(uwpTasks); + foreach (var verbEntry in staticVerbs) + { + string key = verbEntry.Key; + var paths = verbEntry.Value; + string name = key.Split('|')[0]; + string command = key.Split('|')[1]; - return allResults.ToList(); + var verbResult = new BenchmarkResult + { + Name = name, + Type = "Static", + Status = "Static (Not Measured)", + BinaryPath = ExtractExecutablePath(command), + RegistryEntries = paths.Select(p => new RegistryHandlerInfo + { + Path = p, + Location = $"Registry (Shell) - {p.Split('\\')[0]}" + }).ToList(), + InterfaceType = "Static Verb", + DetailedStatus = "Static shell verbs do not go through Hook COM probing and are displayed as not measured.", + TotalTime = 0, + Category = "Static" + }; + + bool anyDisabled = paths.Any(p => p.Split('\\').Last().StartsWith("-")); + verbResult.IsEnabled = !anyDisabled; + verbResult.LocationSummary = string.Join(", ", verbResult.RegistryEntries.Select(e => e.Location).Distinct()); + verbResult.IconLocation = ResolveStaticVerbIcon(paths.First(), verbResult.BinaryPath); + + allResults.Add(verbResult); + progress?.Report(verbResult); } + + var uwpTasks = PackageScanner.ScanPackagedExtensions(packageTargetPath) + .Where(r => r.Clsid.HasValue && !resultsMap.ContainsKey(r.Clsid.Value)) + .Select(async uwpResult => + { + await semaphore.WaitAsync(); + try + { + uwpResult.Category = "UWP"; + await EnrichBenchmarkResultAsync(uwpResult, hookContextPath); + + uwpResult.IsEnabled = !ExtensionManager.IsExtensionBlocked(uwpResult.Clsid!.Value); + uwpResult.LocationSummary = "Modern Shell (UWP)"; + + allResults.Add(uwpResult); + progress?.Report(uwpResult); + } + finally { semaphore.Release(); } + }); + + await Task.WhenAll(uwpTasks); + + return allResults.ToList(); } private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string contextPath) @@ -486,11 +509,6 @@ private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) } } - public List RunBenchmark(string targetPath) - { - return RunSystemBenchmark(ScanMode.Targeted); // Simplified for now - } - public long RunRealShellBenchmark(string? filePath = null) => -1; private string DetermineCategory(IEnumerable locations) diff --git a/ContextMenuProfiler.UI/Core/HookIpcClient.cs b/ContextMenuProfiler.UI/Core/HookIpcClient.cs index a8b8a9e..6face3b 100644 --- a/ContextMenuProfiler.UI/Core/HookIpcClient.cs +++ b/ContextMenuProfiler.UI/Core/HookIpcClient.cs @@ -36,6 +36,8 @@ public class HookCallResult public static class HookIpcClient { + private const string ProtocolPrefix = "CMP1"; + private const string ProtocolMode = "AUTO"; private const string PipeName = "ContextMenuProfilerHook"; private const int ConnectTimeoutMs = 1200; private const int RoundTripTimeoutMs = 2000; @@ -86,20 +88,25 @@ public static async Task GetHookDataAsync(string clsid, string? } swConnect.Stop(); result.connect_ms += Math.Max(0, (long)swConnect.Elapsed.TotalMilliseconds); + try + { + client.ReadMode = PipeTransmissionMode.Message; + } + catch + { + } var swRoundTrip = Stopwatch.StartNew(); using var roundTripCts = new CancellationTokenSource(RoundTripTimeoutMs); - // Format: "CLSID|Path[|DllHint]" - string requestStr = string.IsNullOrEmpty(dllHint) ? $"{clsid}|{path}" : $"{clsid}|{path}|{dllHint}"; + string requestStr = BuildRequest(clsid, path, dllHint); byte[] request = Encoding.UTF8.GetBytes(requestStr); await client.WriteAsync(request, 0, request.Length, roundTripCts.Token); await client.FlushAsync(roundTripCts.Token); - byte[] responseBuf = new byte[65536]; - int read; + string response; try { - read = await client.ReadAsync(responseBuf, 0, responseBuf.Length, roundTripCts.Token); + response = await ReadResponseAsync(client, roundTripCts.Token); } catch (OperationCanceledException) { @@ -112,8 +119,8 @@ public static async Task GetHookDataAsync(string clsid, string? } return result; } - - if (read <= 0) + + if (string.IsNullOrWhiteSpace(response)) { swRoundTrip.Stop(); result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); @@ -124,16 +131,11 @@ public static async Task GetHookDataAsync(string clsid, string? } return result; } - - string response = Encoding.UTF8.GetString(responseBuf, 0, read).TrimEnd('\0'); try { - // 寻找第一个 { 和最后一个 } 确保 JSON 完整 - int start = response.IndexOf('{'); - int end = response.LastIndexOf('}'); - if (start != -1 && end != -1 && end > start) + string? json = ExtractJsonEnvelope(response); + if (!string.IsNullOrEmpty(json)) { - string json = response.Substring(start, end - start + 1); result.data = JsonSerializer.Deserialize(json); swRoundTrip.Stop(); result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); @@ -196,5 +198,45 @@ public static async Task GetMenuNamesAsync(string clsid, string? conte if (data == null || !data.success || string.IsNullOrEmpty(data.names)) return Array.Empty(); return data.names.Split('|', StringSplitOptions.RemoveEmptyEntries); } + + internal static string BuildRequest(string clsid, string path, string? dllHint) + { + if (string.IsNullOrEmpty(dllHint)) + { + return $"{ProtocolPrefix}|{ProtocolMode}|{clsid}|{path}"; + } + return $"{ProtocolPrefix}|{ProtocolMode}|{clsid}|{path}|{dllHint}"; + } + + internal static string? ExtractJsonEnvelope(string response) + { + int start = response.IndexOf('{'); + int end = response.LastIndexOf('}'); + if (start == -1 || end == -1 || end <= start) return null; + return response.Substring(start, end - start + 1); + } + + private static async Task ReadResponseAsync(NamedPipeClientStream client, CancellationToken token) + { + var sb = new StringBuilder(1024); + byte[] buffer = new byte[4096]; + while (true) + { + int read = await client.ReadAsync(buffer, 0, buffer.Length, token); + if (read <= 0) break; + sb.Append(Encoding.UTF8.GetString(buffer, 0, read)); + bool isMessageComplete = false; + try + { + isMessageComplete = client.IsMessageComplete; + } + catch + { + isMessageComplete = read < buffer.Length; + } + if (isMessageComplete) break; + } + return sb.ToString().TrimEnd('\0'); + } } } diff --git a/ContextMenuProfiler.UI/Core/RegistryScanner.cs b/ContextMenuProfiler.UI/Core/RegistryScanner.cs index 5cd3138..782f443 100644 --- a/ContextMenuProfiler.UI/Core/RegistryScanner.cs +++ b/ContextMenuProfiler.UI/Core/RegistryScanner.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; using Microsoft.Win32; @@ -84,6 +85,43 @@ public static Dictionary> ScanHandlers(ScanMode return new Dictionary>(handlers); } + public static Dictionary> ScanHandlersForPath(string targetPath) + { + var handlers = new ConcurrentDictionary>(); + bool isDirectory = Directory.Exists(targetPath); + string ext = isDirectory ? "directory" : Path.GetExtension(targetPath).ToLowerInvariant(); + + ScanLocation(handlers, @"*\shellex\ContextMenuHandlers", "All Files (*)"); + ScanLocation(handlers, @"*\shellex\-ContextMenuHandlers", "All Files (*) [Disabled]"); + + if (isDirectory) + { + ScanLocation(handlers, @"Directory\shellex\ContextMenuHandlers", "Directory"); + ScanLocation(handlers, @"Directory\shellex\-ContextMenuHandlers", "Directory [Disabled]"); + ScanLocation(handlers, @"Folder\shellex\ContextMenuHandlers", "Folder"); + ScanLocation(handlers, @"Drive\shellex\ContextMenuHandlers", "Drive"); + ScanLocation(handlers, @"AllFileSystemObjects\shellex\ContextMenuHandlers", "All File System Objects"); + ScanLocation(handlers, @"Directory\Background\shellex\ContextMenuHandlers", "Directory Background"); + ScanLocation(handlers, @"DesktopBackground\shellex\ContextMenuHandlers", "Desktop Background"); + return new Dictionary>(handlers); + } + + if (!string.IsNullOrEmpty(ext)) + { + ScanLocation(handlers, $@"SystemFileAssociations\{ext}\shellex\ContextMenuHandlers", $"Extension ({ext})"); + ScanLocation(handlers, $@"SystemFileAssociations\{ext}\shellex\-ContextMenuHandlers", $"Extension ({ext}) [Disabled]"); + + string? progId = GetProgID(ext); + if (!string.IsNullOrEmpty(progId)) + { + ScanLocation(handlers, $@"{progId}\shellex\ContextMenuHandlers", $"ProgID ({progId} for {ext})"); + ScanLocation(handlers, $@"{progId}\shellex\-ContextMenuHandlers", $"ProgID ({progId} for {ext}) [Disabled]"); + } + } + + return new Dictionary>(handlers); + } + // Updated signature to accept ConcurrentDictionary private static void ScanLocation(ConcurrentDictionary> handlers, string subKeyPath, string locationName) { @@ -184,6 +222,36 @@ public static Dictionary> ScanStaticVerbs() return new Dictionary>(verbs); } + public static Dictionary> ScanStaticVerbsForPath(string targetPath) + { + var verbs = new ConcurrentDictionary>(); + bool isDirectory = Directory.Exists(targetPath); + string ext = isDirectory ? "directory" : Path.GetExtension(targetPath).ToLowerInvariant(); + + ScanShellKey(verbs, @"*\shell", "All Files (*)"); + + if (isDirectory) + { + ScanShellKey(verbs, @"Directory\shell", "Directory"); + ScanShellKey(verbs, @"Directory\Background\shell", "Directory Background"); + ScanShellKey(verbs, @"Drive\shell", "Drive"); + ScanShellKey(verbs, @"Folder\shell", "Folder"); + return new Dictionary>(verbs); + } + + if (!string.IsNullOrEmpty(ext)) + { + ScanShellKey(verbs, $@"SystemFileAssociations\{ext}\shell", $"Extension ({ext})"); + string? progId = GetProgID(ext); + if (!string.IsNullOrEmpty(progId)) + { + ScanShellKey(verbs, $@"{progId}\shell", $"ProgID ({progId} for {ext})"); + } + } + + return new Dictionary>(verbs); + } + private static void ScanShellKey(ConcurrentDictionary> verbs, string subKeyPath, string locationName) { try diff --git a/ContextMenuProfiler.sln b/ContextMenuProfiler.sln index b88bf9f..a00608c 100644 --- a/ContextMenuProfiler.sln +++ b/ContextMenuProfiler.sln @@ -4,6 +4,8 @@ VisualStudioVersion = 18.3.11520.95 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContextMenuProfiler.UI", "ContextMenuProfiler.UI\ContextMenuProfiler.UI.csproj", "{AE0FB575-F9F7-4FA5-BACF-E007199E47E4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContextMenuProfiler.QualityChecks", "ContextMenuProfiler.QualityChecks\ContextMenuProfiler.QualityChecks.csproj", "{E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +28,18 @@ Global {AE0FB575-F9F7-4FA5-BACF-E007199E47E4}.Release|x64.Build.0 = Release|Any CPU {AE0FB575-F9F7-4FA5-BACF-E007199E47E4}.Release|x86.ActiveCfg = Release|Any CPU {AE0FB575-F9F7-4FA5-BACF-E007199E47E4}.Release|x86.Build.0 = Release|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Debug|x64.Build.0 = Debug|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Debug|x86.Build.0 = Debug|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Release|Any CPU.Build.0 = Release|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Release|x64.ActiveCfg = Release|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Release|x64.Build.0 = Release|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Release|x86.ActiveCfg = Release|Any CPU + {E3E7DF39-6FE8-4F73-A8E2-43D1AAE06CE6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/CODE_QUALITY_AUDIT.md b/docs/CODE_QUALITY_AUDIT.md new file mode 100644 index 0000000..7206a94 --- /dev/null +++ b/docs/CODE_QUALITY_AUDIT.md @@ -0,0 +1,139 @@ +# ContextMenuProfiler 代码质量全量审计与重构蓝图 + +## 1. 文档目的 +本文件用于系统记录当前仓库在架构、可靠性、安全性、可维护性、工程流程等层面的质量问题,并给出可执行的分阶段重构方案。 + +目标不是做“最小修补”,而是将项目演进到可长期维护、可持续发布、可验证质量的工程形态。 + +## 2. 审计范围 +- UI 层:`ContextMenuProfiler.UI` +- Hook/Injector 原生层:`ContextMenuProfiler.Hook` +- 构建与发布:`scripts/`、`.github/workflows/` +- 文档与流程:`docs/`、PR 模板 + +## 3. 当前架构简述 +- 双运行时架构:WPF UI + Explorer 内 C++ Hook DLL。 +- UI 负责扫描与展示,Hook 负责真实探测,双方通过 Named Pipe 交换数据。 +- 构建链路分裂:UI 走 `dotnet build`,Hook 走 bat 脚本单独编译和注入。 + +## 4. 核心问题总览 + +### P0(必须优先处理) +1. IPC 协议可靠性不足 + 当前采用字节流 + 单次读写,缺少长度前缀、消息边界、循环读取机制,存在半包/截断风险。 + +2. IPC 安全边界过弱 + 管道鉴权薄弱,存在本机进程滥用敏感指令的风险,影响可用性与稳定性。 + +3. “分析文件”语义与实现不一致 + 文件分析路径未真正按文件类型聚焦,用户可感知为功能名实不符。 + +4. 测试门禁缺失 + CI 以构建为主,缺少自动化测试和质量阈值,无法防止回归进入主干。 + +### P1(高优先) +1. ViewModel 过重,职责混合 + UI 状态管理、并发调度、系统操作、通知逻辑耦合在单类中,维护成本高。 + +2. 生命周期与并发收尾不完整 + 超时取消、线程回收、模块卸载协同不足,存在长尾稳定性风险。 + +3. 异常处理策略不一致 + 多处吞错或仅记录简要信息,导致故障定位链路不完整。 + +4. 文档与实现存在偏差 + 部分“已知限制”与当前代码行为不一致,影响决策与排障。 + +### P2(中长期治理) +1. 字符串状态驱动业务判断 + 状态与展示耦合,易碎,不利于国际化和重构。 + +2. 工程边界分裂 + 解决方案与脚本构建链路并存,认知成本和跨环境一致性成本偏高。 + +3. 代码卫生问题 + 局部存在冗长试探性实现、噪音注释与历史残留,降低可读性。 + +## 5. 详细问题清单与影响面 + +### 5.1 架构与边界 +- **问题**:应用服务层缺失,ViewModel 直接承担系统级职责。 +- **影响**:单测困难、变更风险集中、功能迭代容易牵一发动全身。 +- **建议**:引入 Application 层用例服务,ViewModel 只保留状态映射与命令触发。 + +### 5.2 可靠性与并发 +- **问题**:IPC 缺乏明确帧协议,超时和退出路径一致性不足。 +- **影响**:偶发卡死、假失败、资源泄漏、难复现问题。 +- **建议**:统一请求/响应 envelope、超时取消策略、线程退出协定。 + +### 5.3 安全与滥用面 +- **问题**:高敏感操作入口缺乏足够的最小权限与调用约束。 +- **影响**:被本机其他进程误用/滥用的可能性增大。 +- **建议**:收紧管道访问控制、强化命令白名单和来源校验。 + +### 5.4 功能语义与产品一致性 +- **问题**:“分析文件”等核心用户路径与实现偏离。 +- **影响**:用户信任受损,Issue 反复出现。 +- **建议**:先修语义一致性,再做性能和体验优化。 + +### 5.5 工程流程与发布质量 +- **问题**:CI 缺少测试、静态分析、告警预算门槛。 +- **影响**:质量退化不可见,发布风险后移到线上。 +- **建议**:建立分层门禁(编译、测试、静态分析、发布验收)。 + +## 6. 重构目标状态(Target State) +- 明确四层职责:UI / Application / Domain / Infrastructure。 +- IPC 协议类型化:版本号、长度前缀、请求 ID、错误码。 +- 错误可观测:统一异常分类、结构化日志、追踪 ID 贯通。 +- 质量可验证:CI 强制测试与静态分析,发布前自动验收。 + +## 7. 分阶段实施路线 + +### 阶段 A:止血与可验证(P0) +- 修复“分析文件”语义路径。 +- 建立最小测试基线:核心扫描服务与协议解析。 +- CI 接入 `dotnet test`,失败阻断合并。 +- 建立 IPC 基础协议壳(请求头/响应头/错误码)。 + +### 阶段 B:可靠性加固(P1) +- 完成 IPC 全量迁移到类型化协议。 +- 统一超时、取消、线程回收和模块卸载收尾策略。 +- 收敛吞错点,建立分级异常处理规范。 + +### 阶段 C:架构解耦(P1) +- 拆分重型 ViewModel 为多个用例服务。 +- UI 层改为状态投影,系统调用下沉到 Infrastructure。 +- 引入 typed status model,移除字符串驱动逻辑。 + +### 阶段 D:工程化治理(P2) +- 完善 CI 门禁:静态分析、警告预算、制品校验。 +- 统一构建说明,收敛脚本/解决方案边界。 +- 更新文档与模板,使其与实现持续一致。 + +## 8. 验收标准 +- 功能一致性:用户可感知路径与实现一致,关键 Issue 可关闭。 +- 稳定性:超时、并发、资源释放场景可重复验证通过。 +- 安全性:敏感操作入口具备最小权限和基本防滥用能力。 +- 工程质量:PR 必须通过构建、测试、静态检查门禁。 +- 可维护性:核心模块可单测、可定位、可演进。 + +## 9. 风险与约束 +- Hook 位于 Explorer 进程内,任何重构都需渐进式推进并配套回归策略。 +- IPC 协议升级需兼容过渡,避免 UI/Hook 双端版本错配。 +- 需要先建立基础测试与诊断能力,再推进中大型架构拆分。 + +## 10. 建议的执行顺序 +1. 先做阶段 A,确保“可验证 + 可回归”。 +2. 再做阶段 B,优先消除隐藏稳定性雷点。 +3. 之后推进阶段 C,完成结构性解耦。 +4. 最后以阶段 D 固化流程,防止质量回退。 + +--- + +如果后续开始实装,可基于本文件拆分为: +- `REFACTOR_PHASE_A.md` +- `REFACTOR_PHASE_B.md` +- `REFACTOR_PHASE_C.md` +- `REFACTOR_PHASE_D.md` + +并在每阶段内维护“目标、改动、验证结果、回滚方案”四件套。 From faa8d3c178237eb9a10d9b92da0f98fa71da7c35 Mon Sep 17 00:00:00 2001 From: Haerbin23456 <60066765+Haerbin23456@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:44:18 +0800 Subject: [PATCH 02/73] refactor(phase-a): add verification pipeline, localization guards, and smoke checklist --- ContextMenuProfiler.QualityChecks/Program.cs | 83 +++++++++++++++++++ .../Converters/LoadTimeToTextConverter.cs | 6 +- .../Core/BenchmarkService.cs | 3 +- .../Core/Services/LocalizationService.cs | 18 ++++ .../ViewModels/DashboardViewModel.cs | 24 ++++-- README.md | 4 + docs/REFACTOR_PHASE_A.md | 30 +++++++ docs/SMOKE_CHECKLIST.md | 55 ++++++++++++ scripts/build_hook.bat | 8 ++ scripts/verify_local.bat | 31 +++++++ 10 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 docs/REFACTOR_PHASE_A.md create mode 100644 docs/SMOKE_CHECKLIST.md create mode 100644 scripts/verify_local.bat diff --git a/ContextMenuProfiler.QualityChecks/Program.cs b/ContextMenuProfiler.QualityChecks/Program.cs index 6af0024..f9396df 100644 --- a/ContextMenuProfiler.QualityChecks/Program.cs +++ b/ContextMenuProfiler.QualityChecks/Program.cs @@ -1,4 +1,6 @@ +using System.Reflection; using ContextMenuProfiler.UI.Core; +using ContextMenuProfiler.UI.Core.Services; static void AssertEqual(string expected, string actual, string caseName) { @@ -8,6 +10,30 @@ static void AssertEqual(string expected, string actual, string caseName) } } +static void AssertTrue(bool condition, string caseName, string? detail = null) +{ + if (!condition) + { + throw new InvalidOperationException($"{caseName} failed.{(string.IsNullOrEmpty(detail) ? "" : $" {detail}")}"); + } +} + +static string FindFileUpward(string relativePath) +{ + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + string candidate = Path.Combine(current.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + current = current.Parent; + } + + throw new FileNotFoundException($"Could not locate file by relative path: {relativePath}"); +} + static void AssertNull(string? value, string caseName) { if (value != null) @@ -28,6 +54,63 @@ static void AssertNull(string? value, string caseName) string? wrapped = HookIpcClient.ExtractJsonEnvelope("noise{\"success\":true,\"state\":1}tail"); AssertEqual("{\"success\":true,\"state\":1}", wrapped ?? "", "ExtractJsonWrapped"); +const BindingFlags instanceNonPublic = BindingFlags.Instance | BindingFlags.NonPublic; +var resourcesField = typeof(LocalizationService).GetField("_resources", instanceNonPublic); +AssertTrue(resourcesField != null, "LocalizationResourcesFieldExists"); + +var localizationService = LocalizationService.Instance; +var resources = resourcesField!.GetValue(localizationService) as Dictionary>; +AssertTrue(resources != null, "LocalizationResourcesReadable"); +AssertTrue(resources!.ContainsKey("en-US"), "LocalizationHasEnglish"); +AssertTrue(resources.ContainsKey("zh-CN"), "LocalizationHasChinese"); + +var enKeys = resources["en-US"].Keys.ToHashSet(StringComparer.Ordinal); +var zhKeys = resources["zh-CN"].Keys.ToHashSet(StringComparer.Ordinal); + +var missingInChinese = enKeys.Except(zhKeys, StringComparer.Ordinal).ToList(); +var missingInEnglish = zhKeys.Except(enKeys, StringComparer.Ordinal).ToList(); + +AssertTrue( + missingInChinese.Count == 0, + "LocalizationMissingInChinese", + missingInChinese.Count == 0 ? null : string.Join(", ", missingInChinese.Take(10)) +); + +AssertTrue( + missingInEnglish.Count == 0, + "LocalizationMissingInEnglish", + missingInEnglish.Count == 0 ? null : string.Join(", ", missingInEnglish.Take(10)) +); + +string[] criticalKeys = +{ + "App.Title", + "Hook.Active", + "Hook.NotInjected", + "Dashboard.ScanSystem", + "Dashboard.AnalyzeFile", + "Dashboard.Status.ScanComplete" +}; + +foreach (var key in criticalKeys) +{ + AssertTrue(resources["en-US"].TryGetValue(key, out var enValue) && !string.IsNullOrWhiteSpace(enValue), $"LocalizationEnglishValue:{key}"); + AssertTrue(resources["zh-CN"].TryGetValue(key, out var zhValue) && !string.IsNullOrWhiteSpace(zhValue), $"LocalizationChineseValue:{key}"); +} + +string benchmarkServicePath = FindFileUpward(@"ContextMenuProfiler.UI\Core\BenchmarkService.cs"); +string benchmarkServiceSource = File.ReadAllText(benchmarkServicePath); + +AssertTrue( + benchmarkServiceSource.Contains("RunBenchmarkAsync(targetPath)", StringComparison.Ordinal), + "AnalyzeFileUsesPathSpecificBenchmark" +); + +AssertTrue( + !benchmarkServiceSource.Contains("return RunSystemBenchmark(ScanMode.Targeted)", StringComparison.Ordinal), + "AnalyzeFileDoesNotFallbackToSystemScan" +); + Console.WriteLine("Quality checks passed."); if (string.Equals(Environment.GetEnvironmentVariable("CMP_LIVE_PROBE"), "1", StringComparison.Ordinal)) diff --git a/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs index d533dd9..92af8a9 100644 --- a/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs +++ b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Windows.Data; +using ContextMenuProfiler.UI.Core.Services; namespace ContextMenuProfiler.UI.Converters { @@ -8,7 +9,8 @@ public class LoadTimeToTextConverter : IMultiValueConverter { public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { - if (values.Length < 2) return "N/A"; + string noneText = LocalizationService.Instance["Dashboard.Value.None"]; + if (values.Length < 2) return noneText; long ms = 0; if (values[0] is long l) ms = l; @@ -18,7 +20,7 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur if (ShouldShowNa(status, ms)) { - return "N/A"; + return noneText; } return $"{ms} ms"; diff --git a/ContextMenuProfiler.UI/Core/BenchmarkService.cs b/ContextMenuProfiler.UI/Core/BenchmarkService.cs index 794b1ea..5cf9d47 100644 --- a/ContextMenuProfiler.UI/Core/BenchmarkService.cs +++ b/ContextMenuProfiler.UI/Core/BenchmarkService.cs @@ -74,8 +74,7 @@ public List RunSystemBenchmark(ScanMode mode = ScanMode.Targete public async Task> RunSystemBenchmarkAsync(ScanMode mode = ScanMode.Targeted, IProgress? progress = null) { - bool useFolderContext = mode == ScanMode.Full ? false : false; - using var fileContext = ShellTestContext.Create(useFolderContext); + using var fileContext = ShellTestContext.Create(false); var registryHandlers = RegistryScanner.ScanHandlers(mode); var staticVerbs = RegistryScanner.ScanStaticVerbs(); return await RunBenchmarkCoreAsync(registryHandlers, staticVerbs, null, fileContext.Path, progress); diff --git a/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs index caf51d3..59e47a6 100644 --- a/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs +++ b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs @@ -180,12 +180,21 @@ private static Dictionary BuildEnglish() ["Dashboard.Status.ScanningFile"] = "Scanning: {0}", ["Dashboard.Status.ScanComplete"] = "Scan complete. Found {0} extensions.", ["Dashboard.Status.ScanFailed"] = "Scan failed.", + ["Dashboard.Status.ReconnectingHook"] = "Reconnecting Hook...", + ["Dashboard.Status.InitializingHook"] = "Initializing Hook Service...", ["Dashboard.Status.Ready"] = "Ready to scan", ["Dashboard.Status.Unknown"] = "Unknown Status", ["Dashboard.Notify.ScanComplete.Title"] = "Scan Complete", ["Dashboard.Notify.ScanComplete.Message"] = "Found {0} extensions.", ["Dashboard.Notify.ScanCompleteForFile.Message"] = "Found {0} extensions for {1}.", ["Dashboard.Notify.ScanFailed.Title"] = "Scan Failed", + ["Dashboard.Notify.InjectFailed.Title"] = "Inject Failed", + ["Dashboard.Notify.InjectFailed.Message"] = "Injector or Hook DLL not found, or elevation was denied.", + ["Dashboard.Notify.HookConnected.Title"] = "Connected", + ["Dashboard.Notify.HookConnected.Message"] = "Hook service is now active.", + ["Dashboard.Notify.HookPartial.Title"] = "Partial Success", + ["Dashboard.Notify.HookPartial.Message"] = "DLL injected, but pipe not responding yet.", + ["Dashboard.Notify.ReconnectFailed.Title"] = "Reconnect Failed", ["Dashboard.Dialog.SelectFileTitle"] = "Select a file to analyze context menu", ["Dashboard.Dialog.AllFilesFilter"] = "All files (*.*)|*.*", ["Dashboard.RealLoad.Measuring"] = "Measuring...", @@ -272,12 +281,21 @@ private static Dictionary BuildChinese() ["Dashboard.Status.ScanningFile"] = "正在扫描:{0}", ["Dashboard.Status.ScanComplete"] = "扫描完成,共找到 {0} 个扩展。", ["Dashboard.Status.ScanFailed"] = "扫描失败。", + ["Dashboard.Status.ReconnectingHook"] = "正在重连 Hook...", + ["Dashboard.Status.InitializingHook"] = "正在初始化 Hook 服务...", ["Dashboard.Status.Ready"] = "准备开始扫描", ["Dashboard.Status.Unknown"] = "未知状态", ["Dashboard.Notify.ScanComplete.Title"] = "扫描完成", ["Dashboard.Notify.ScanComplete.Message"] = "共找到 {0} 个扩展。", ["Dashboard.Notify.ScanCompleteForFile.Message"] = "已为 {1} 找到 {0} 个扩展。", ["Dashboard.Notify.ScanFailed.Title"] = "扫描失败", + ["Dashboard.Notify.InjectFailed.Title"] = "注入失败", + ["Dashboard.Notify.InjectFailed.Message"] = "未找到 Injector 或 Hook DLL,或管理员授权被拒绝。", + ["Dashboard.Notify.HookConnected.Title"] = "已连接", + ["Dashboard.Notify.HookConnected.Message"] = "Hook 服务已激活。", + ["Dashboard.Notify.HookPartial.Title"] = "部分成功", + ["Dashboard.Notify.HookPartial.Message"] = "DLL 已注入,但管道暂未响应。", + ["Dashboard.Notify.ReconnectFailed.Title"] = "重连失败", ["Dashboard.Dialog.SelectFileTitle"] = "选择要分析右键菜单的文件", ["Dashboard.Dialog.AllFilesFilter"] = "所有文件 (*.*)|*.*", ["Dashboard.RealLoad.Measuring"] = "测量中...", diff --git a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs index 5532285..689f849 100644 --- a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs @@ -192,7 +192,7 @@ partial void OnShowMeasuredOnlyChanged(bool value) } [ObservableProperty] - private string _realLoadTime = "N/A"; // Display string for Real Shell Benchmark + private string _realLoadTime = LocalizationService.Instance["Dashboard.Value.None"]; // Display string for Real Shell Benchmark private string _lastScanMode = "None"; // "System" or "File" private string _lastScanPath = ""; @@ -259,36 +259,42 @@ public DashboardViewModel() [RelayCommand] private async Task ReconnectHook() { - StatusText = "Reconnecting Hook..."; + StatusText = LocalizationService.Instance["Dashboard.Status.ReconnectingHook"]; IsBusy = true; try { bool injectOk = await HookService.Instance.InjectAsync(); if (!injectOk) { - NotificationService.Instance.ShowError("Inject Failed", "Injector or Hook DLL not found, or elevation was denied."); + NotificationService.Instance.ShowError( + LocalizationService.Instance["Dashboard.Notify.InjectFailed.Title"], + LocalizationService.Instance["Dashboard.Notify.InjectFailed.Message"]); return; } await Task.Delay(1000); // Give it a second await HookService.Instance.GetStatusAsync(); if (CurrentHookStatus == HookStatus.Active) { - NotificationService.Instance.ShowSuccess("Connected", "Hook service is now active."); + NotificationService.Instance.ShowSuccess( + LocalizationService.Instance["Dashboard.Notify.HookConnected.Title"], + LocalizationService.Instance["Dashboard.Notify.HookConnected.Message"]); } else { - NotificationService.Instance.ShowWarning("Partial Success", "DLL injected, but pipe not responding yet."); + NotificationService.Instance.ShowWarning( + LocalizationService.Instance["Dashboard.Notify.HookPartial.Title"], + LocalizationService.Instance["Dashboard.Notify.HookPartial.Message"]); } } catch (Exception ex) { LogService.Instance.Error("Reconnect Failed", ex); - NotificationService.Instance.ShowError("Reconnect Failed", ex.Message); + NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.ReconnectFailed.Title"], ex.Message); } finally { IsBusy = false; - StatusText = "Ready"; + StatusText = LocalizationService.Instance["Dashboard.Status.Ready"]; } } @@ -297,7 +303,7 @@ private async Task AutoEnsureHook() var status = await HookService.Instance.GetStatusAsync(); if (status == HookStatus.Disconnected) { - StatusText = "Initializing Hook Service..."; + StatusText = LocalizationService.Instance["Dashboard.Status.InitializingHook"]; await HookService.Instance.InjectAsync(); } } @@ -708,7 +714,7 @@ private void UpdateStats() long realLoadMs = Results .Where(r => r.IsEnabled) .Sum(r => r.WallClockTime > 0 ? r.WallClockTime : Math.Max(0, r.TotalTime)); - RealLoadTime = realLoadMs > 0 ? $"{realLoadMs} ms" : "N/A"; + RealLoadTime = realLoadMs > 0 ? $"{realLoadMs} ms" : LocalizationService.Instance["Dashboard.Value.None"]; } } } diff --git a/README.md b/README.md index 6324484..98d9c50 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ - .NET 8 SDK ### 构建与运行 +0. (推荐)先做本地完整验证:`scripts\verify_local.bat` + - 会按顺序执行 Hook 构建(验证模式)、`dotnet build`、`QualityChecks`。 + - 验证模式会设置 `CMP_SKIP_ROOT_COPY=1`,避免 Explorer 占用 DLL 时因为复制失败中断验证。 + - 交互回归可按 `docs/SMOKE_CHECKLIST.md` 执行。 1. 以管理员身份运行 `scripts\redeploy.bat`。 - 脚本会自动查找您的 Visual Studio 安装路径,构建 Hook DLL 并将其注入到 Explorer 中。 2. 打开 `ContextMenuProfiler.UI` 开始扫描。 diff --git a/docs/REFACTOR_PHASE_A.md b/docs/REFACTOR_PHASE_A.md new file mode 100644 index 0000000..a700d30 --- /dev/null +++ b/docs/REFACTOR_PHASE_A.md @@ -0,0 +1,30 @@ +# Phase A - 止血与可验证(执行中) + +## 目标 +- 保证“分析文件”路径不再退化为系统扫描。 +- 建立最小但稳定的本地质量门禁,减少人工回归循环。 +- 在不破坏现有功能的前提下,清理明显的试探性/硬编码实现。 + +## 已完成 +- [x] 新增本地一键验证脚本 `scripts/verify_local.bat`。 + - 顺序执行:Hook 构建(验证模式)→ `dotnet build` → `QualityChecks`。 +- [x] `scripts/build_hook.bat` 支持 `CMP_SKIP_ROOT_COPY=1`。 + - 避免 Explorer 占用根目录 DLL 时,验证流程被误判失败。 +- [x] 扩展 `ContextMenuProfiler.QualityChecks/Program.cs`: + - IPC 请求构造/JSON 包络解析校验。 + - 中英文本地化 key 完整性与关键 key 非空校验。 + - “分析文件语义守卫”:断言 `BenchmarkService` 保持按路径分析逻辑。 +- [x] 清理 `BenchmarkService` 中冗余分支:`mode == ScanMode.Full ? false : false`。 +- [x] 清理 `DashboardViewModel`/`LoadTimeToTextConverter` 中关键硬编码文案,统一接入本地化。 + +## 下一批(进行中) +- [ ] 将更多“字符串状态判断”收敛为类型化状态(先从 UI 展示层开始)。 +- [ ] 补充可重复的手动 Smoke 清单(与自动验证互补)。 +- [ ] 审核 Hook IPC 的请求/响应边界与错误码一致性(为后续协议类型化做准备)。 + +## 回归命令 +- `scripts\verify_local.bat` + +## 说明 +- 当前阶段优先“可验证”和“防回归”,暂不做大规模架构拆分。 +- 任何改动需保证上述回归命令通过。 diff --git a/docs/SMOKE_CHECKLIST.md b/docs/SMOKE_CHECKLIST.md new file mode 100644 index 0000000..846bdd6 --- /dev/null +++ b/docs/SMOKE_CHECKLIST.md @@ -0,0 +1,55 @@ +# ContextMenuProfiler 手动冒烟清单 + +## 0) 执行前准备 +- 以普通权限执行:`scripts\verify_local.bat` +- 预期:输出 `Verification passed.` +- 若第 1 步提示 DLL 被占用,这是预期场景(验证模式会自动跳过根目录复制)。 + +## 1) Hook 连接与状态 +1. 启动 `ContextMenuProfiler.UI`。 +2. 观察右上角 Hook 状态文案。 +3. 点击 `Reconnect / Inject`(若当前为未连接)。 +4. 预期: + - 状态可从“未连接”转为“已连接/已激活”之一。 + - 不出现崩溃或 UI 卡死。 + +## 2) 系统扫描路径 +1. 点击 `Scan System`。 +2. 扫描过程中滚动列表、切换排序、输入搜索词。 +3. 预期: + - UI 可响应(允许有短暂忙碌,但不应长时间假死)。 + - 扫描完成后显示结果数量与总耗时。 + +## 3) 分析文件语义路径(重点) +1. 点击 `Analyze File`,选择一个常见文件(例如 `.txt`)。 +2. 观察结果中的 `Registry` / `Location` 信息。 +3. 预期: + - 结果以该文件类型相关项为主(包括 `SystemFileAssociations` 或对应 ProgID 路径)。 + - 不应退化为“等同系统扫描”的全量结果模式。 + +## 4) 扩展开关管理 +1. 在结果中选择一个可安全测试的扩展。 +2. 切换启用/禁用开关。 +3. 预期: + - 不崩溃。 + - 状态变更为 pending restart 类提示。 + - 刷新后状态与预期一致。 + +## 5) 多语言显示 +1. 进入 `Settings` 切换语言(中/英)。 +2. 回到 Dashboard 观察状态栏与通知文本。 +3. 预期: + - 不出现明显未翻译键名(如 `Dashboard.xxx`)。 + - `None/无`、Hook 重连相关提示按语言切换。 + +## 6) 故障收集(若失败) +- 记录失败步骤编号(如 `3)`)。 +- 附带以下信息: + - `ContextMenuProfiler.UI/startup_log.txt` + - `ContextMenuProfiler.UI/crash_log.txt` + - 根目录 `hook_internal.log`(若存在) +- 尽量提供“前一步操作 + 当前表现 + 预期表现”。 + +--- + +这份清单优先覆盖高频回归点:Hook 状态、扫描路径、分析文件语义、UI 交互与本地化显示。 diff --git a/scripts/build_hook.bat b/scripts/build_hook.bat index d579c78..5471146 100644 --- a/scripts/build_hook.bat +++ b/scripts/build_hook.bat @@ -79,9 +79,17 @@ if %ERRORLEVEL% NEQ 0 ( exit /b %ERRORLEVEL% ) +if /I "%CMP_SKIP_ROOT_COPY%"=="1" ( + echo Skipping root binary copy because CMP_SKIP_ROOT_COPY=1 + echo Build outputs are available in "%BUILD_DIR%" + echo Done. + exit /b 0 +) + copy /Y "%BUILD_DIR%\ContextMenuProfiler.Hook.dll" "ContextMenuProfiler.Hook.dll" >nul if %ERRORLEVEL% NEQ 0 ( echo Error: Failed to copy ContextMenuProfiler.Hook.dll + echo Hint: if this file is locked by Explorer, rerun with CMP_SKIP_ROOT_COPY=1 for validation-only builds. exit /b 1 ) copy /Y "%BUILD_DIR%\ContextMenuProfiler.Injector.exe" "ContextMenuProfiler.Injector.exe" >nul diff --git a/scripts/verify_local.bat b/scripts/verify_local.bat new file mode 100644 index 0000000..cad71d4 --- /dev/null +++ b/scripts/verify_local.bat @@ -0,0 +1,31 @@ +@echo off +setlocal EnableExtensions +cd /d "%~dp0.." + +set "CONFIG=Release" +if /I not "%~1"=="" set "CONFIG=%~1" + +echo [1/3] Building Hook core (validation mode, skip root copy)... +set "CMP_SKIP_ROOT_COPY=1" +call scripts\build_hook.bat +if %ERRORLEVEL% NEQ 0 ( + echo Verification failed at step 1: Hook build + exit /b %ERRORLEVEL% +) + +echo [2/3] Building solution (%CONFIG%)... +dotnet build ContextMenuProfiler.sln -c %CONFIG% +if %ERRORLEVEL% NEQ 0 ( + echo Verification failed at step 2: dotnet build + exit /b %ERRORLEVEL% +) + +echo [3/3] Running quality checks (%CONFIG%)... +dotnet run --project ContextMenuProfiler.QualityChecks\ContextMenuProfiler.QualityChecks.csproj -c %CONFIG% --no-build +if %ERRORLEVEL% NEQ 0 ( + echo Verification failed at step 3: quality checks + exit /b %ERRORLEVEL% +) + +echo Verification passed. +exit /b 0 From 3edb300c76f45d66f0c885cd433b10112786387b Mon Sep 17 00:00:00 2001 From: Haerbin23456 <60066765+Haerbin23456@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:45:36 +0800 Subject: [PATCH 03/73] refactor(ui): replace dashboard last-scan string mode with enum --- .../ViewModels/DashboardViewModel.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs index 689f849..d55b542 100644 --- a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs @@ -33,6 +33,13 @@ public partial class CategoryItem : ObservableObject public partial class DashboardViewModel : ObservableObject { + private enum LastScanMode + { + None, + System, + File + } + private readonly BenchmarkService _benchmarkService; private CancellationTokenSource? _filterCts; @@ -194,7 +201,7 @@ partial void OnShowMeasuredOnlyChanged(bool value) [ObservableProperty] private string _realLoadTime = LocalizationService.Instance["Dashboard.Value.None"]; // Display string for Real Shell Benchmark - private string _lastScanMode = "None"; // "System" or "File" + private LastScanMode _lastScanMode = LastScanMode.None; private string _lastScanPath = ""; private long _scanOrderCounter = 0; @@ -316,11 +323,11 @@ private bool CanExecuteBenchmark() [RelayCommand(CanExecute = nameof(CanExecuteBenchmark))] private async Task Refresh() { - if (_lastScanMode == "System") + if (_lastScanMode == LastScanMode.System) { await ScanSystem(); } - else if (_lastScanMode == "File" && !string.IsNullOrEmpty(_lastScanPath)) + else if (_lastScanMode == LastScanMode.File && !string.IsNullOrEmpty(_lastScanPath)) { await ScanFile(_lastScanPath); } @@ -329,7 +336,7 @@ private async Task Refresh() [RelayCommand(CanExecute = nameof(CanExecuteBenchmark))] private async Task ScanSystem() { - _lastScanMode = "System"; + _lastScanMode = LastScanMode.System; StatusText = LocalizationService.Instance["Dashboard.Status.ScanningSystem"]; IsBusy = true; RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Measuring"]; @@ -444,7 +451,7 @@ private async Task ScanFile(string filePath) { if (string.IsNullOrEmpty(filePath)) return; - _lastScanMode = "File"; + _lastScanMode = LastScanMode.File; _lastScanPath = filePath; StatusText = string.Format(LocalizationService.Instance["Dashboard.Status.ScanningFile"], filePath); IsBusy = true; From b15a154430c351bfb4a607f67cfc959b520a3c5c Mon Sep 17 00:00:00 2001 From: Haerbin23456 <60066765+Haerbin23456@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:47:16 +0800 Subject: [PATCH 04/73] refactor(ui): localize dashboard extension actions and status messages --- .../Core/Services/LocalizationService.cs | 32 +++++++++++++++++++ .../ViewModels/DashboardViewModel.cs | 32 ++++++++++++------- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs index 59e47a6..846ef2a 100644 --- a/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs +++ b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs @@ -180,6 +180,8 @@ private static Dictionary BuildEnglish() ["Dashboard.Status.ScanningFile"] = "Scanning: {0}", ["Dashboard.Status.ScanComplete"] = "Scan complete. Found {0} extensions.", ["Dashboard.Status.ScanFailed"] = "Scan failed.", + ["Dashboard.Status.DisabledPendingRestart"] = "Disabled (Pending Restart)", + ["Dashboard.Status.EnabledPendingRestart"] = "Enabled (Pending Restart)", ["Dashboard.Status.ReconnectingHook"] = "Reconnecting Hook...", ["Dashboard.Status.InitializingHook"] = "Initializing Hook Service...", ["Dashboard.Status.Ready"] = "Ready to scan", @@ -195,6 +197,20 @@ private static Dictionary BuildEnglish() ["Dashboard.Notify.HookPartial.Title"] = "Partial Success", ["Dashboard.Notify.HookPartial.Message"] = "DLL injected, but pipe not responding yet.", ["Dashboard.Notify.ReconnectFailed.Title"] = "Reconnect Failed", + ["Dashboard.Notify.ToggleFailed.Title"] = "Toggle Failed", + ["Dashboard.Notify.DeleteNotSupported.Title"] = "Not Supported", + ["Dashboard.Notify.DeleteNotSupported.Message"] = "Deleting UWP extensions is not supported. Use Disable instead.", + ["Dashboard.Notify.DeleteSuccess.Title"] = "Deleted", + ["Dashboard.Notify.DeleteSuccess.Message"] = "Extension '{0}' has been deleted.", + ["Dashboard.Notify.DeleteFailed.Title"] = "Delete Failed", + ["Dashboard.Notify.CopySuccess.Title"] = "Copied", + ["Dashboard.Notify.CopySuccess.Message"] = "CLSID copied to clipboard.", + ["Dashboard.Notify.CopyFailed.Title"] = "Copy Failed", + ["Dashboard.Notify.CopyFailed.Message"] = "Clipboard is locked by another process.", + ["Dashboard.Notify.OpenRegistryFailed.Title"] = "Error", + ["Dashboard.Notify.OpenRegistryFailed.Message"] = "Failed to open registry editor.", + ["Dashboard.Dialog.ConfirmDelete.Title"] = "Confirm Delete", + ["Dashboard.Dialog.ConfirmDelete.Message"] = "Are you sure you want to permanently delete the extension '{0}'?\n\nThis action will remove the registry keys and cannot be undone.", ["Dashboard.Dialog.SelectFileTitle"] = "Select a file to analyze context menu", ["Dashboard.Dialog.AllFilesFilter"] = "All files (*.*)|*.*", ["Dashboard.RealLoad.Measuring"] = "Measuring...", @@ -281,6 +297,8 @@ private static Dictionary BuildChinese() ["Dashboard.Status.ScanningFile"] = "正在扫描:{0}", ["Dashboard.Status.ScanComplete"] = "扫描完成,共找到 {0} 个扩展。", ["Dashboard.Status.ScanFailed"] = "扫描失败。", + ["Dashboard.Status.DisabledPendingRestart"] = "已禁用(待重启)", + ["Dashboard.Status.EnabledPendingRestart"] = "已启用(待重启)", ["Dashboard.Status.ReconnectingHook"] = "正在重连 Hook...", ["Dashboard.Status.InitializingHook"] = "正在初始化 Hook 服务...", ["Dashboard.Status.Ready"] = "准备开始扫描", @@ -296,6 +314,20 @@ private static Dictionary BuildChinese() ["Dashboard.Notify.HookPartial.Title"] = "部分成功", ["Dashboard.Notify.HookPartial.Message"] = "DLL 已注入,但管道暂未响应。", ["Dashboard.Notify.ReconnectFailed.Title"] = "重连失败", + ["Dashboard.Notify.ToggleFailed.Title"] = "切换失败", + ["Dashboard.Notify.DeleteNotSupported.Title"] = "不支持", + ["Dashboard.Notify.DeleteNotSupported.Message"] = "不支持删除 UWP 扩展,请使用禁用功能。", + ["Dashboard.Notify.DeleteSuccess.Title"] = "删除成功", + ["Dashboard.Notify.DeleteSuccess.Message"] = "扩展“{0}”已删除。", + ["Dashboard.Notify.DeleteFailed.Title"] = "删除失败", + ["Dashboard.Notify.CopySuccess.Title"] = "已复制", + ["Dashboard.Notify.CopySuccess.Message"] = "CLSID 已复制到剪贴板。", + ["Dashboard.Notify.CopyFailed.Title"] = "复制失败", + ["Dashboard.Notify.CopyFailed.Message"] = "剪贴板被其他进程占用。", + ["Dashboard.Notify.OpenRegistryFailed.Title"] = "错误", + ["Dashboard.Notify.OpenRegistryFailed.Message"] = "打开注册表编辑器失败。", + ["Dashboard.Dialog.ConfirmDelete.Title"] = "确认删除", + ["Dashboard.Dialog.ConfirmDelete.Message"] = "确定要永久删除扩展“{0}”吗?\n\n此操作将删除对应注册表项,且不可恢复。", ["Dashboard.Dialog.SelectFileTitle"] = "选择要分析右键菜单的文件", ["Dashboard.Dialog.AllFilesFilter"] = "所有文件 (*.*)|*.*", ["Dashboard.RealLoad.Measuring"] = "测量中...", diff --git a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs index d55b542..551a7b4 100644 --- a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs @@ -553,7 +553,7 @@ private void ToggleExtension(BenchmarkResult item) ExtensionManager.DisableRegistryKey(entry.Path); } } - item.Status = "Disabled (Pending Restart)"; + item.Status = LocalizationService.Instance["Dashboard.Status.DisabledPendingRestart"]; } else // User turned it ON (IsEnabled is now true) { @@ -569,7 +569,7 @@ private void ToggleExtension(BenchmarkResult item) ExtensionManager.EnableRegistryKey(entry.Path); } } - item.Status = "Enabled (Pending Restart)"; + item.Status = LocalizationService.Instance["Dashboard.Status.EnabledPendingRestart"]; } UpdateStats(); @@ -577,7 +577,7 @@ private void ToggleExtension(BenchmarkResult item) catch (Exception ex) { LogService.Instance.Error("Toggle Extension Failed", ex); - NotificationService.Instance.ShowError("Toggle Failed", ex.Message); + NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.ToggleFailed.Title"], ex.Message); // Revert item.IsEnabled = !item.IsEnabled; } @@ -590,14 +590,16 @@ private void DeleteExtension(BenchmarkResult item) if (item.Type == "UWP") { - NotificationService.Instance.ShowWarning("Not Supported", "Deleting UWP extensions is not supported. Use Disable instead."); + NotificationService.Instance.ShowWarning( + LocalizationService.Instance["Dashboard.Notify.DeleteNotSupported.Title"], + LocalizationService.Instance["Dashboard.Notify.DeleteNotSupported.Message"]); return; } // Confirm var result = System.Windows.MessageBox.Show( - $"Are you sure you want to permanently delete the extension '{item.Name}'?\n\nThis action will remove the registry keys and cannot be undone.", - "Confirm Delete", + string.Format(LocalizationService.Instance["Dashboard.Dialog.ConfirmDelete.Message"], item.Name), + LocalizationService.Instance["Dashboard.Dialog.ConfirmDelete.Title"], System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Warning); @@ -618,12 +620,14 @@ private void DeleteExtension(BenchmarkResult item) DisplayResults.Remove(item); UpdateStats(); - NotificationService.Instance.ShowSuccess("Deleted", $"Extension '{item.Name}' has been deleted."); + NotificationService.Instance.ShowSuccess( + LocalizationService.Instance["Dashboard.Notify.DeleteSuccess.Title"], + string.Format(LocalizationService.Instance["Dashboard.Notify.DeleteSuccess.Message"], item.Name)); } catch (Exception ex) { LogService.Instance.Error("Delete Extension Failed", ex); - NotificationService.Instance.ShowError("Delete Failed", ex.Message); + NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.DeleteFailed.Title"], ex.Message); } } @@ -638,7 +642,9 @@ private async Task CopyClsid(BenchmarkResult item) try { Clipboard.SetText(clsid); - NotificationService.Instance.ShowSuccess("Copied", "CLSID copied to clipboard."); + NotificationService.Instance.ShowSuccess( + LocalizationService.Instance["Dashboard.Notify.CopySuccess.Title"], + LocalizationService.Instance["Dashboard.Notify.CopySuccess.Message"]); return; } catch (System.Runtime.InteropServices.COMException ex) when ((uint)ex.ErrorCode == 0x800401D0) @@ -652,7 +658,9 @@ private async Task CopyClsid(BenchmarkResult item) break; } } - NotificationService.Instance.ShowError("Copy Failed", "Clipboard is locked by another process."); + NotificationService.Instance.ShowError( + LocalizationService.Instance["Dashboard.Notify.CopyFailed.Title"], + LocalizationService.Instance["Dashboard.Notify.CopyFailed.Message"]); } } @@ -682,7 +690,9 @@ private void OpenInRegistry(string path) } catch (Exception) { - NotificationService.Instance.ShowError("Error", "Failed to open registry editor."); + NotificationService.Instance.ShowError( + LocalizationService.Instance["Dashboard.Notify.OpenRegistryFailed.Title"], + LocalizationService.Instance["Dashboard.Notify.OpenRegistryFailed.Message"]); } } From 4eb4f6542c8305d54f9332b8e26e6488d378d48f Mon Sep 17 00:00:00 2001 From: Haerbin23456 <60066765+Haerbin23456@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:48:41 +0800 Subject: [PATCH 05/73] refactor(ui): centralize dashboard category tags as constants --- .../ViewModels/DashboardViewModel.cs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs index 551a7b4..34fb9e8 100644 --- a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs @@ -40,6 +40,17 @@ private enum LastScanMode File } + private static class CategoryTag + { + public const string All = "All"; + public const string File = "File"; + public const string Folder = "Folder"; + public const string Background = "Background"; + public const string Drive = "Drive"; + public const string Uwp = "UWP"; + public const string Static = "Static"; + } + private readonly BenchmarkService _benchmarkService; private CancellationTokenSource? _filterCts; @@ -63,7 +74,7 @@ partial void OnSearchTextChanged(string value) } [ObservableProperty] - private string _selectedCategory = "All"; + private string _selectedCategory = CategoryTag.All; [ObservableProperty] private int _selectedCategoryIndex = 0; @@ -138,7 +149,7 @@ private bool MatchesFilter(BenchmarkResult result) } // Category Match - bool categoryMatch = SelectedCategory == "All" || result.Category == SelectedCategory; + bool categoryMatch = SelectedCategory == CategoryTag.All || result.Category == SelectedCategory; if (!categoryMatch) return false; // Search Match @@ -512,13 +523,13 @@ private void ApplyLocalizedCategoryNames() { Categories = new ObservableCollection { - new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.All"], Tag = "All", Icon = SymbolRegular.TableMultiple20, IsActive = true }, - new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Files"], Tag = "File", Icon = SymbolRegular.Document20 }, - new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Folders"], Tag = "Folder", Icon = SymbolRegular.Folder20 }, - new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Background"], Tag = "Background", Icon = SymbolRegular.Image20 }, - new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Drives"], Tag = "Drive", Icon = SymbolRegular.HardDrive20 }, - new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.UwpModern"], Tag = "UWP", Icon = SymbolRegular.Box20 }, - new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.StaticVerbs"], Tag = "Static", Icon = SymbolRegular.PuzzlePiece20 } + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.All"], Tag = CategoryTag.All, Icon = SymbolRegular.TableMultiple20, IsActive = true }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Files"], Tag = CategoryTag.File, Icon = SymbolRegular.Document20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Folders"], Tag = CategoryTag.Folder, Icon = SymbolRegular.Folder20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Background"], Tag = CategoryTag.Background, Icon = SymbolRegular.Image20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Drives"], Tag = CategoryTag.Drive, Icon = SymbolRegular.HardDrive20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.UwpModern"], Tag = CategoryTag.Uwp, Icon = SymbolRegular.Box20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.StaticVerbs"], Tag = CategoryTag.Static, Icon = SymbolRegular.PuzzlePiece20 } }; if (SelectedCategoryIndex < 0 || SelectedCategoryIndex >= Categories.Count) { From 120642245dcc1dea7c46ecc0bb10100c8a774e2e Mon Sep 17 00:00:00 2001 From: Haerbin23456 <60066765+Haerbin23456@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:22:17 +0800 Subject: [PATCH 06/73] refactor(core): centralize benchmark semantics and remove key magic-string logic --- .../Converters/LoadTimeToTextConverter.cs | 6 +- .../Converters/StatusToVisibilityConverter.cs | 16 +--- .../Converters/TypeToIconConverter.cs | 7 +- .../Core/BenchmarkSemantics.cs | 88 +++++++++++++++++++ .../Core/BenchmarkService.cs | 48 +++++----- ContextMenuProfiler.UI/Core/PackageScanner.cs | 4 +- .../ViewModels/DashboardViewModel.cs | 6 +- 7 files changed, 127 insertions(+), 48 deletions(-) create mode 100644 ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs diff --git a/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs index 92af8a9..62595cd 100644 --- a/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs +++ b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Windows.Data; +using ContextMenuProfiler.UI.Core; using ContextMenuProfiler.UI.Core.Services; namespace ContextMenuProfiler.UI.Converters @@ -32,10 +33,7 @@ private static bool ShouldShowNa(string status, long ms) if (string.IsNullOrWhiteSpace(status)) return true; - return status.Contains("Fallback", StringComparison.OrdinalIgnoreCase) || - status.Contains("Load Error", StringComparison.OrdinalIgnoreCase) || - status.Contains("Orphaned", StringComparison.OrdinalIgnoreCase) || - status.Contains("Missing", StringComparison.OrdinalIgnoreCase) || + return BenchmarkSemantics.IsFallbackLikeStatus(status) || status.Contains("Not Measured", StringComparison.OrdinalIgnoreCase) || status.Contains("Unsupported", StringComparison.OrdinalIgnoreCase) || status.Contains("No Menu", StringComparison.OrdinalIgnoreCase); diff --git a/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs b/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs index a07f197..6e3c065 100644 --- a/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs +++ b/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Windows; using System.Windows.Data; +using ContextMenuProfiler.UI.Core; using ContextMenuProfiler.UI.Core.Services; namespace ContextMenuProfiler.UI.Converters @@ -20,28 +21,19 @@ public object Convert(object value, Type targetType, object parameter, CultureIn if (param == "NotUWP") { string? type = value as string; - return (type != "UWP" && type != "UWP / Packaged COM") ? Visibility.Visible : Visibility.Collapsed; + return !BenchmarkSemantics.IsPackagedExtensionType(type) ? Visibility.Visible : Visibility.Collapsed; } if (param == "Fallback") { string? statusStr = value as string; - bool isFallback = statusStr != null && (statusStr.Contains("Fallback") || statusStr.Contains("Error") || statusStr.Contains("Orphaned") || statusStr.Contains("Missing")); + bool isFallback = BenchmarkSemantics.IsFallbackLikeStatus(statusStr); return isFallback ? Visibility.Visible : Visibility.Collapsed; } if (value is string status) { - bool isWarning = status.StartsWith("Load Error") || - status.Contains("Exception") || - status.Contains("Failed") || - status.Contains("Not Registered") || - status.Contains("Invalid") || - status.Contains("Not Found") || - status.Contains("Fallback") || - status.Contains("No Menu") || - status.Contains("Orphaned") || - status.Contains("Missing"); + bool isWarning = BenchmarkSemantics.IsWarningLikeStatus(status); if (param == "Inverse") { diff --git a/ContextMenuProfiler.UI/Converters/TypeToIconConverter.cs b/ContextMenuProfiler.UI/Converters/TypeToIconConverter.cs index a81c65c..5e33696 100644 --- a/ContextMenuProfiler.UI/Converters/TypeToIconConverter.cs +++ b/ContextMenuProfiler.UI/Converters/TypeToIconConverter.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Windows.Data; +using ContextMenuProfiler.UI.Core; using Wpf.Ui.Controls; namespace ContextMenuProfiler.UI.Converters @@ -11,9 +12,9 @@ public object Convert(object value, Type targetType, object parameter, CultureIn { if (value is string type) { - if (type == "UWP") return SymbolRegular.AppGeneric24; - if (type == "COM") return SymbolRegular.PuzzlePiece24; // Default generic icon - if (type == "Static") return SymbolRegular.WindowConsole20; + if (string.Equals(type, BenchmarkSemantics.Type.Uwp, StringComparison.OrdinalIgnoreCase)) return SymbolRegular.AppGeneric24; + if (string.Equals(type, BenchmarkSemantics.Type.Com, StringComparison.OrdinalIgnoreCase)) return SymbolRegular.PuzzlePiece24; // Default generic icon + if (string.Equals(type, BenchmarkSemantics.Type.Static, StringComparison.OrdinalIgnoreCase)) return SymbolRegular.WindowConsole20; } return SymbolRegular.PuzzlePiece24; // Default fallback } diff --git a/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs b/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs new file mode 100644 index 0000000..0b47e3a --- /dev/null +++ b/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs @@ -0,0 +1,88 @@ +using System; + +namespace ContextMenuProfiler.UI.Core +{ + public static class BenchmarkSemantics + { + public static class Type + { + public const string Com = "COM"; + public const string Uwp = "UWP"; + public const string Static = "Static"; + public const string PackagedExtension = "Packaged Extension"; + public const string PackagedCom = "Packaged COM"; + public const string UwpPackagedCom = "UWP / Packaged COM"; + } + + public static class Category + { + public const string File = "File"; + public const string Folder = "Folder"; + public const string Background = "Background"; + public const string Drive = "Drive"; + public const string Uwp = "UWP"; + public const string Static = "Static"; + } + + public static class Status + { + public const string Unknown = "Unknown"; + public const string Ok = "OK"; + public const string VerifiedViaHook = "Verified via Hook"; + public const string HookLoadedNoMenu = "Hook Loaded (No Menu)"; + public const string OrphanedMissingDll = "Orphaned / Missing DLL"; + public const string IpcTimeout = "IPC Timeout"; + public const string LoadError = "Load Error"; + public const string RegistryFallback = "Registry Fallback"; + public const string StaticNotMeasured = "Static (Not Measured)"; + public const string SkippedKnownUnstable = "Skipped (Known Unstable)"; + public const string DisabledPendingRestart = "Disabled (Pending Restart)"; + public const string EnabledPendingRestart = "Enabled (Pending Restart)"; + } + + public static bool IsPackagedExtensionType(string? type) + { + if (string.IsNullOrWhiteSpace(type)) + { + return false; + } + + return string.Equals(type, Type.Uwp, StringComparison.OrdinalIgnoreCase) + || string.Equals(type, Type.PackagedExtension, StringComparison.OrdinalIgnoreCase) + || string.Equals(type, Type.PackagedCom, StringComparison.OrdinalIgnoreCase) + || string.Equals(type, Type.UwpPackagedCom, StringComparison.OrdinalIgnoreCase); + } + + public static bool IsFallbackLikeStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + { + return false; + } + + return status.Contains("Fallback", StringComparison.OrdinalIgnoreCase) + || status.Contains("Error", StringComparison.OrdinalIgnoreCase) + || status.Contains("Orphaned", StringComparison.OrdinalIgnoreCase) + || status.Contains("Missing", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsWarningLikeStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + { + return false; + } + + return status.StartsWith(Status.LoadError, StringComparison.OrdinalIgnoreCase) + || status.Contains("Exception", StringComparison.OrdinalIgnoreCase) + || status.Contains("Failed", StringComparison.OrdinalIgnoreCase) + || status.Contains("Not Registered", StringComparison.OrdinalIgnoreCase) + || status.Contains("Invalid", StringComparison.OrdinalIgnoreCase) + || status.Contains("Not Found", StringComparison.OrdinalIgnoreCase) + || status.Contains("Fallback", StringComparison.OrdinalIgnoreCase) + || status.Contains("No Menu", StringComparison.OrdinalIgnoreCase) + || status.Contains("Orphaned", StringComparison.OrdinalIgnoreCase) + || status.Contains("Missing", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/ContextMenuProfiler.UI/Core/BenchmarkService.cs b/ContextMenuProfiler.UI/Core/BenchmarkService.cs index 5cf9d47..e57fda9 100644 --- a/ContextMenuProfiler.UI/Core/BenchmarkService.cs +++ b/ContextMenuProfiler.UI/Core/BenchmarkService.cs @@ -17,8 +17,8 @@ public class BenchmarkResult { public string Name { get; set; } = ""; public Guid? Clsid { get; set; } - public string Status { get; set; } = "Unknown"; - public string Type { get; set; } = "COM"; // Legacy COM, UWP, Static + public string Status { get; set; } = BenchmarkSemantics.Status.Unknown; + public string Type { get; set; } = BenchmarkSemantics.Type.Com; // Legacy COM, UWP, Static public string? Path { get; set; } public List RegistryEntries { get; set; } = new List(); public long TotalTime { get; set; } @@ -43,7 +43,7 @@ public class BenchmarkResult public string? FriendlyName { get; set; } public string? IconSource { get; set; } public string? LocationSummary { get; set; } - public string Category { get; set; } = "File"; + public string Category { get; set; } = BenchmarkSemantics.Category.File; } internal class ClsidMetadata @@ -121,7 +121,7 @@ private async Task> RunBenchmarkCoreAsync( var result = new BenchmarkResult { Clsid = clsid, - Type = "COM", + Type = BenchmarkSemantics.Type.Com, RegistryEntries = handlerInfos.ToList(), Name = meta.Name, BinaryPath = meta.BinaryPath, @@ -159,8 +159,8 @@ private async Task> RunBenchmarkCoreAsync( var verbResult = new BenchmarkResult { Name = name, - Type = "Static", - Status = "Static (Not Measured)", + Type = BenchmarkSemantics.Type.Static, + Status = BenchmarkSemantics.Status.StaticNotMeasured, BinaryPath = ExtractExecutablePath(command), RegistryEntries = paths.Select(p => new RegistryHandlerInfo { @@ -170,7 +170,7 @@ private async Task> RunBenchmarkCoreAsync( InterfaceType = "Static Verb", DetailedStatus = "Static shell verbs do not go through Hook COM probing and are displayed as not measured.", TotalTime = 0, - Category = "Static" + Category = BenchmarkSemantics.Category.Static }; bool anyDisabled = paths.Any(p => p.Split('\\').Last().StartsWith("-")); @@ -189,7 +189,7 @@ private async Task> RunBenchmarkCoreAsync( await semaphore.WaitAsync(); try { - uwpResult.Category = "UWP"; + uwpResult.Category = BenchmarkSemantics.Category.Uwp; await EnrichBenchmarkResultAsync(uwpResult, hookContextPath); uwpResult.IsEnabled = !ExtensionManager.IsExtensionBlocked(uwpResult.Clsid!.Value); @@ -212,7 +212,7 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con if (SkipKnownUnstableHandlers && IsKnownUnstableHandler(result)) { - result.Status = "Skipped (Known Unstable)"; + result.Status = BenchmarkSemantics.Status.SkippedKnownUnstable; result.DetailedStatus = "Skipped Hook invocation for a known unstable system handler to avoid scan-wide IPC stalls."; result.InterfaceType = "Skipped"; result.CreateTime = 0; @@ -225,7 +225,7 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con // Check for Orphaned / Missing DLL if (!string.IsNullOrEmpty(result.BinaryPath) && !File.Exists(result.BinaryPath)) { - result.Status = "Orphaned / Missing DLL"; + result.Status = BenchmarkSemantics.Status.OrphanedMissingDll; result.DetailedStatus = $"The file '{result.BinaryPath}' was not found on disk. This extension is likely corrupted or uninstalled."; } @@ -242,15 +242,15 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con if (!string.IsNullOrEmpty(hookData.names)) { // Keep packaged/UWP display names stable to avoid garbled menu-title replacements. - if (!string.Equals(result.Type, "UWP", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(result.Type, BenchmarkSemantics.Type.Uwp, StringComparison.OrdinalIgnoreCase)) { result.Name = hookData.names.Replace("|", ", "); } - if (result.Status == "Unknown") result.Status = "Verified via Hook"; + if (result.Status == BenchmarkSemantics.Status.Unknown) result.Status = BenchmarkSemantics.Status.VerifiedViaHook; } - else if (result.Status == "Unknown" || result.Status == "OK") + else if (result.Status == BenchmarkSemantics.Status.Unknown || result.Status == BenchmarkSemantics.Status.Ok) { - result.Status = "Hook Loaded (No Menu)"; + result.Status = BenchmarkSemantics.Status.HookLoadedNoMenu; result.DetailedStatus = "The extension was loaded by the Hook service but it did not provide any context menu items for the test context."; } @@ -271,27 +271,27 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con { if (!string.IsNullOrEmpty(hookData.error) && hookData.error.Contains("Timeout", StringComparison.OrdinalIgnoreCase)) { - result.Status = "IPC Timeout"; + result.Status = BenchmarkSemantics.Status.IpcTimeout; result.DetailedStatus = $"Hook service timed out while probing this extension. Error: {hookData.error}"; } else { - result.Status = "Load Error"; + result.Status = BenchmarkSemantics.Status.LoadError; result.DetailedStatus = $"The Hook service failed to load this extension. Error: {hookData.error ?? "Unknown Error"}"; } } else if (hookData == null) { - if (result.Status != "Load Error" && result.Status != "Orphaned / Missing DLL") + if (result.Status != BenchmarkSemantics.Status.LoadError && result.Status != BenchmarkSemantics.Status.OrphanedMissingDll) { if (hookCall.roundtrip_ms >= 1900) { - result.Status = "IPC Timeout"; + result.Status = BenchmarkSemantics.Status.IpcTimeout; result.DetailedStatus = "Hook service response timed out for this extension. Data is based on registry scan only."; } else { - result.Status = "Registry Fallback"; + result.Status = BenchmarkSemantics.Status.RegistryFallback; result.DetailedStatus = "The Hook service could not be reached or failed to process this extension. Data is based on registry scan only."; } } @@ -513,12 +513,12 @@ private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) private string DetermineCategory(IEnumerable locations) { var locs = locations.ToList(); - if (locs.Any(l => l.Contains("Background"))) return "Background"; - if (locs.Any(l => l.Contains("Drive"))) return "Drive"; - if (locs.Any(l => l.Contains("Directory") || l.Contains("Folder"))) return "Folder"; - if (locs.Any(l => l.Contains("All Files") || l.Contains("Extension") || l.Contains("All File System Objects"))) return "File"; + if (locs.Any(l => l.Contains("Background"))) return BenchmarkSemantics.Category.Background; + if (locs.Any(l => l.Contains("Drive"))) return BenchmarkSemantics.Category.Drive; + if (locs.Any(l => l.Contains("Directory") || l.Contains("Folder"))) return BenchmarkSemantics.Category.Folder; + if (locs.Any(l => l.Contains("All Files") || l.Contains("Extension") || l.Contains("All File System Objects"))) return BenchmarkSemantics.Category.File; - return "File"; // Default + return BenchmarkSemantics.Category.File; // Default } } } diff --git a/ContextMenuProfiler.UI/Core/PackageScanner.cs b/ContextMenuProfiler.UI/Core/PackageScanner.cs index c57d65a..e9b7710 100644 --- a/ContextMenuProfiler.UI/Core/PackageScanner.cs +++ b/ContextMenuProfiler.UI/Core/PackageScanner.cs @@ -121,8 +121,8 @@ private static bool TryParseVerb(Package package, XElement verb, Dictionary Date: Wed, 18 Mar 2026 07:24:30 +0800 Subject: [PATCH 07/73] refactor(ui): replace dashboard status trigger literals with shared constants --- .../Core/BenchmarkSemantics.cs | 5 ++++ .../Views/Pages/DashboardPage.xaml | 25 ++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs b/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs index 0b47e3a..34b8b6d 100644 --- a/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs +++ b/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs @@ -4,6 +4,11 @@ namespace ContextMenuProfiler.UI.Core { public static class BenchmarkSemantics { + public const string StatusRegistryFallback = Status.RegistryFallback; + public const string StatusHookLoadedNoMenu = Status.HookLoadedNoMenu; + public const string StatusLoadError = Status.LoadError; + public const string StatusOrphanedMissingDll = Status.OrphanedMissingDll; + public static class Type { public const string Com = "COM"; diff --git a/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml b/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml index 87a41db..fb001c4 100644 --- a/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml +++ b/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml @@ -7,6 +7,7 @@ xmlns:pages="clr-namespace:ContextMenuProfiler.UI.Views.Pages" xmlns:viewmodels="clr-namespace:ContextMenuProfiler.UI.ViewModels" xmlns:converters="clr-namespace:ContextMenuProfiler.UI.Converters" + xmlns:core="clr-namespace:ContextMenuProfiler.UI.Core" xmlns:services="clr-namespace:ContextMenuProfiler.UI.Core.Services" xmlns:helpers="clr-namespace:ContextMenuProfiler.UI.Core.Helpers" mc:Ignorable="d" @@ -342,16 +343,16 @@ - + - + - + - + @@ -384,10 +385,10 @@