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..393febf 100644 --- a/ContextMenuProfiler.Hook/src/ipc_server.cpp +++ b/ContextMenuProfiler.Hook/src/ipc_server.cpp @@ -5,14 +5,61 @@ static const LONG kMaxConcurrentPipeClients = 4; static volatile LONG g_ActivePipeClients = 0; static const DWORD kWorkerTimeoutMs = 1800; +static const DWORD kFrameHeaderBytes = 4; +static const size_t kMaxRequestBytes = 16384; +static const size_t kMaxResponseBytes = 65536; struct IpcWorkItem { - char request[2048]; + std::string request; char response[65536]; int maxLen; volatile LONG releaseByWorker; }; +static bool ReadExactFromPipe(HANDLE hPipe, void* buffer, DWORD bytesToRead) { + BYTE* cursor = reinterpret_cast(buffer); + DWORD totalRead = 0; + while (totalRead < bytesToRead) { + DWORD chunkRead = 0; + BOOL ok = ReadFile(hPipe, cursor + totalRead, bytesToRead - totalRead, &chunkRead, NULL); + if (!ok || chunkRead == 0) { + return false; + } + + totalRead += chunkRead; + } + + return true; +} + +static bool WriteFrameToPipe(HANDLE hPipe, const char* payload, DWORD payloadLength) { + if (payloadLength > static_cast(kMaxResponseBytes)) { + return false; + } + + DWORD written = 0; + DWORD frameLength = payloadLength; + if (!WriteFile(hPipe, &frameLength, kFrameHeaderBytes, &written, NULL) || written != kFrameHeaderBytes) { + return false; + } + + if (payloadLength == 0) { + FlushFileBuffers(hPipe); + return true; + } + + if (!WriteFile(hPipe, payload, payloadLength, &written, NULL) || written != payloadLength) { + return false; + } + + FlushFileBuffers(hPipe); + return true; +} + +static void WriteJsonFrameToPipe(HANDLE hPipe, const char* json) { + WriteFrameToPipe(hPipe, json, static_cast(strlen(json))); +} + void DoIpcWorkInternal(const char* request, char* response, int maxLen) { std::string reqStr = request; @@ -25,21 +72,33 @@ void DoIpcWorkInternal(const char* request, char* response, int maxLen) { return; } - std::string mode = "AUTO"; + std::string mode; std::string clsidStr, pathStr, dllHintStr; - if (reqStr.substr(0, 4) == "COM|") { - mode = "COM"; - reqStr = reqStr.substr(4); - } else if (reqStr.substr(0, 5) == "ECMD|") { - mode = "ECMD"; - reqStr = reqStr.substr(5); + if (reqStr.rfind("CMP1|", 0) != 0) { + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_PROTOCOL\",\"error\":\"Unsupported Protocol\"}"); + return; + } + + 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 (mode != "AUTO" && mode != "COM" && mode != "ECMD") { + snprintf(response, maxLen, "{\"success\":false,\"code\":\"E_MODE\",\"error\":\"Unsupported Mode\"}"); + return; } // 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 +114,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 @@ -99,7 +167,7 @@ void DoIpcWork(const char* request, char* response, int maxLen) { DWORD WINAPI DoIpcWorkThread(LPVOID param) { IpcWorkItem* item = (IpcWorkItem*)param; item->response[0] = '\0'; - DoIpcWork(item->request, item->response, item->maxLen); + DoIpcWork(item->request.c_str(), item->response, item->maxLen); if (InterlockedCompareExchange(&item->releaseByWorker, 0, 0) == 1) { delete item; } @@ -110,41 +178,45 @@ DWORD WINAPI HandlePipeClientThread(LPVOID param) { HANDLE hPipe = (HANDLE)param; CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - char req[2048]; - DWORD read = 0; - DWORD written = 0; - - if (ReadFile(hPipe, req, 2047, &read, NULL) && read > 0) { - req[read] = '\0'; + DWORD requestLength = 0; + if (ReadExactFromPipe(hPipe, &requestLength, kFrameHeaderBytes)) { + if (requestLength == 0 || requestLength > static_cast(kMaxRequestBytes)) { + WriteJsonFrameToPipe(hPipe, "{\"success\":false,\"code\":\"E_REQ_TOO_LARGE\",\"error\":\"Request Too Large\"}"); + } else { + std::string req(requestLength, '\0'); + if (!ReadExactFromPipe(hPipe, &req[0], requestLength)) { + WriteJsonFrameToPipe(hPipe, "{\"success\":false,\"code\":\"E_REQ_READ\",\"error\":\"Request Read Failed\"}"); + } else { IpcWorkItem* workItem = new IpcWorkItem(); - strncpy_s(workItem->request, sizeof(workItem->request), req, _TRUNCATE); + workItem->request = std::move(req); workItem->response[0] = '\0'; - workItem->maxLen = 65535; + workItem->maxLen = static_cast(kMaxResponseBytes) - 1; workItem->releaseByWorker = 0; HANDLE hWorkThread = CreateThread(NULL, 0, DoIpcWorkThread, workItem, 0, NULL); if (!hWorkThread) { - const char* errRes = "{\"success\":false,\"error\":\"Hook Worker Launch Failed\"}"; - WriteFile(hPipe, errRes, (DWORD)strlen(errRes), &written, NULL); - FlushFileBuffers(hPipe); + WriteJsonFrameToPipe(hPipe, "{\"success\":false,\"error\":\"Hook Worker Launch Failed\"}"); delete workItem; } else { DWORD waitRc = WaitForSingleObject(hWorkThread, kWorkerTimeoutMs); if (waitRc == WAIT_OBJECT_0) { - int resLen = (int)strlen(workItem->response); + DWORD resLen = static_cast(strnlen_s(workItem->response, workItem->maxLen)); if (resLen > 0) { - WriteFile(hPipe, workItem->response, (DWORD)resLen, &written, NULL); - FlushFileBuffers(hPipe); + WriteFrameToPipe(hPipe, workItem->response, resLen); + } else { + WriteJsonFrameToPipe(hPipe, "{\"success\":false,\"error\":\"Empty Hook Response\"}"); } delete workItem; } else { - const char* timeoutRes = "{\"success\":false,\"error\":\"Hook Worker Timeout\"}"; - WriteFile(hPipe, timeoutRes, (DWORD)strlen(timeoutRes), &written, NULL); - FlushFileBuffers(hPipe); + WriteJsonFrameToPipe(hPipe, "{\"success\":false,\"error\":\"Hook Worker Timeout\"}"); InterlockedExchange(&workItem->releaseByWorker, 1); } CloseHandle(hWorkThread); } + } + } + } else { + WriteJsonFrameToPipe(hPipe, "{\"success\":false,\"code\":\"E_REQ_HEADER\",\"error\":\"Request Header Read Failed\"}"); } DisconnectNamedPipe(hPipe); @@ -160,17 +232,12 @@ DWORD WINAPI PipeThread(LPVOID) { Gdiplus::GdiplusStartupInput gsi; Gdiplus::GdiplusStartup(&g_GdiplusToken, &gsi, NULL); - PSECURITY_DESCRIPTOR pSD = (PSECURITY_DESCRIPTOR)LocalAlloc(LPTR, SECURITY_DESCRIPTOR_MIN_LENGTH); - InitializeSecurityDescriptor(pSD, SECURITY_DESCRIPTOR_REVISION); - SetSecurityDescriptorDacl(pSD, TRUE, NULL, FALSE); - SECURITY_ATTRIBUTES sa = { sizeof(sa), pSD, FALSE }; - while (!g_ShouldExit) { HANDLE hPipe = CreateNamedPipeA( "\\\\.\\pipe\\ContextMenuProfilerHook", PIPE_ACCESS_DUPLEX, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, - PIPE_UNLIMITED_INSTANCES, 65536, 65536, 0, &sa); + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, 65536, 65536, 0, NULL); if (hPipe == INVALID_HANDLE_VALUE) { Sleep(100); continue; } if (ConnectNamedPipe(hPipe, NULL) || GetLastError() == ERROR_PIPE_CONNECTED) { @@ -182,9 +249,7 @@ DWORD WINAPI PipeThread(LPVOID) { if (active > kMaxConcurrentPipeClients) { InterlockedDecrement(&g_ActivePipeClients); const char* busyRes = "{\"success\":false,\"error\":\"Hook Busy\"}"; - DWORD written = 0; - WriteFile(hPipe, busyRes, (DWORD)strlen(busyRes), &written, NULL); - FlushFileBuffers(hPipe); + WriteJsonFrameToPipe(hPipe, busyRes); DisconnectNamedPipe(hPipe); CloseHandle(hPipe); continue; @@ -203,7 +268,6 @@ DWORD WINAPI PipeThread(LPVOID) { Gdiplus::GdiplusShutdown(g_GdiplusToken); MH_Uninitialize(); - LocalFree(pSD); CoUninitialize(); LogToFile(L"--- Pipe Thread Exited Cleanly ---\n"); return 0; 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..fd2173b --- /dev/null +++ b/ContextMenuProfiler.QualityChecks/Program.cs @@ -0,0 +1,828 @@ +using System.Reflection; +using ContextMenuProfiler.UI.Core; +using ContextMenuProfiler.UI.Core.Services; + +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 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 string ReadSource(string relativePath) +{ + return File.ReadAllText(FindFileUpward(relativePath)); +} + +static void AssertSourceDoesNotContainAny(string source, string caseName, params string[] forbiddenLiterals) +{ + var hit = forbiddenLiterals.Where(l => source.Contains($"\"{l}\"", StringComparison.Ordinal)).ToList(); + AssertTrue(hit.Count == 0, caseName, hit.Count == 0 ? null : string.Join(", ", hit)); +} + +static void AssertSourceContains(string source, string requiredLiteral, string caseName) +{ + AssertTrue(source.Contains(requiredLiteral, StringComparison.Ordinal), caseName); +} + +static void AssertSourceNotContains(string source, string forbiddenLiteral, string caseName) +{ + AssertTrue(!source.Contains(forbiddenLiteral, StringComparison.Ordinal), caseName); +} + +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"); + +AssertEqual( + BenchmarkSemantics.Category.Background, + BenchmarkSemantics.ResolveCategoryFromLocations(new[] { "Directory", "Background" }), + "ResolveCategoryBackgroundPriority" +); + +AssertEqual( + BenchmarkSemantics.Category.Drive, + BenchmarkSemantics.ResolveCategoryFromLocations(new[] { "Drive", "Directory" }), + "ResolveCategoryDrivePriority" +); + +AssertEqual( + BenchmarkSemantics.Category.Folder, + BenchmarkSemantics.ResolveCategoryFromLocations(new[] { "Directory" }), + "ResolveCategoryFolderHint" +); + +AssertEqual( + BenchmarkSemantics.Category.File, + BenchmarkSemantics.ResolveCategoryFromLocations(new[] { "All Files" }), + "ResolveCategoryFileHint" +); + +AssertEqual( + BenchmarkSemantics.Category.File, + BenchmarkSemantics.ResolveCategoryFromLocations(Array.Empty()), + "ResolveCategoryDefault" +); + +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", + "Dashboard.Value.UnknownWithClsid", + "Dashboard.Value.ManifestAppLogo" +}; + +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 benchmarkServiceSource = ReadSource(@"ContextMenuProfiler.UI\Core\BenchmarkService.cs"); +string benchmarkSemanticsSource = ReadSource(@"ContextMenuProfiler.UI\Core\BenchmarkSemantics.cs"); +string comRegistrySemanticsSource = ReadSource(@"ContextMenuProfiler.UI\Core\ComRegistrySemantics.cs"); +string packageManifestSemanticsSource = ReadSource(@"ContextMenuProfiler.UI\Core\PackageManifestSemantics.cs"); +string benchmarkStatisticsSource = ReadSource(@"ContextMenuProfiler.UI\Core\BenchmarkStatistics.cs"); +string hookIpcClientSource = ReadSource(@"ContextMenuProfiler.UI\Core\HookIpcClient.cs"); +string hookIpcSemanticsSource = ReadSource(@"ContextMenuProfiler.UI\Core\HookIpcSemantics.cs"); +string registryScannerSource = ReadSource(@"ContextMenuProfiler.UI\Core\RegistryScanner.cs"); +string extensionManagerSource = ReadSource(@"ContextMenuProfiler.UI\Core\ExtensionManager.cs"); +string packageScannerSource = ReadSource(@"ContextMenuProfiler.UI\Core\PackageScanner.cs"); +string shellUtilsSource = ReadSource(@"ContextMenuProfiler.UI\Core\Helpers\ShellUtils.cs"); +string dashboardViewModelSource = ReadSource(@"ContextMenuProfiler.UI\ViewModels\DashboardViewModel.cs"); +string registryPathHelperSource = ReadSource(@"ContextMenuProfiler.UI\Core\Helpers\RegistryPathHelper.cs"); +string nullOrEmptyConverterSource = ReadSource(@"ContextMenuProfiler.UI\Converters\NullOrEmptyToLocalizedConverter.cs"); +string iconToImageConverterSource = ReadSource(@"ContextMenuProfiler.UI\Converters\IconToImageConverter.cs"); +string statusVisibilityConverterSource = ReadSource(@"ContextMenuProfiler.UI\Converters\StatusToVisibilityConverter.cs"); +string typeToIconConverterSource = ReadSource(@"ContextMenuProfiler.UI\Converters\TypeToIconConverter.cs"); +string dashboardPageXamlSource = ReadSource(@"ContextMenuProfiler.UI\Views\Pages\DashboardPage.xaml"); + +AssertSourceContains(benchmarkServiceSource, "RunBenchmarkAsync(targetPath)", "AnalyzeFileUsesPathSpecificBenchmark"); +AssertSourceNotContains(benchmarkServiceSource, "return RunSystemBenchmark(ScanMode.Targeted)", "AnalyzeFileDoesNotFallbackToSystemScan"); +AssertSourceNotContains(benchmarkServiceSource, "return await RunSystemBenchmarkAsync(ScanMode.Targeted, progress)", "AnalyzeFileDoesNotFallbackToSystemScanAsync"); + +AssertTrue( + benchmarkSemanticsSource.Contains("MaxParallelProbeTasks = 8", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("IpcTimeoutLikeRoundtripThresholdMs = 1900", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("HookReconnectStabilizationDelayMs = 1000", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("ClipboardRetryAttempts = 5", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("ClipboardRetryDelayMs = 100", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("ClipboardCantOpenHResult = 0x800401D0", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("RealShellBenchmarkUnsupportedMs = -1", StringComparison.Ordinal), + "BenchmarkSemanticsDefinesRuntimeProbeConstants" +); + +AssertTrue( + benchmarkSemanticsSource.Contains("private static int ResolveLocationPriority", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("private static bool ContainsAnyLocationHint", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("resolvedPriority switch", StringComparison.Ordinal), + "BenchmarkSemanticsUsesPriorityBasedLocationResolution" +); + +AssertTrue( + benchmarkSemanticsSource.Contains("StaticVerbRegistryShellPrefix = \"Registry (Shell) - \"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("StaticVerbDisabledKeyPrefix = \"-\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildStaticVerbRegistryLocation", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("IsStaticVerbRegistryPathDisabled", StringComparison.Ordinal), + "BenchmarkSemanticsDefinesStaticVerbRegistryHelpers" +); + +AssertTrue( + benchmarkSemanticsSource.Contains("Timeout = \"Timeout\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static bool IsTimeoutLikeError", StringComparison.Ordinal), + "BenchmarkSemanticsDefinesTimeoutErrorHelper" +); + +AssertTrue( + benchmarkSemanticsSource.Contains("SkipUnstableHandlersEnvVar = \"CMP_SKIP_UNSTABLE_HANDLERS\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static bool IsSkipUnstableHandlersEnabled", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static bool ContainsKnownUnstableHandlerToken", StringComparison.Ordinal), + "BenchmarkSemanticsDefinesUnstableHandlerHelpers" +); + +AssertTrue( + benchmarkSemanticsSource.Contains("public static class RegistryLocationLabel", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("DisabledSuffix = \" [Disabled]\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildDisabledRegistryLocationLabel", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildExtensionRegistryLocationLabel", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildProgIdRegistryLocationLabel", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildRegistryHandlerLocation", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static class RegistryPathPattern", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("AnyAssociationType = \"*\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("DirectoryAssociationType = \"directory\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("FolderAssociationType = \"folder\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("DirectoryBackgroundAssociationType = @\"directory\\background\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("AllFilesHandlers = @\"*\\shellex\\ContextMenuHandlers\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildSystemFileAssociationHandlers", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildProgIdHandlers", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildSystemFileAssociationShell", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("BuildProgIdShell", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("IsDirectoryLikeAssociationType", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static class RegistryToken", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("ExtensionPrefix = \".\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static bool LooksLikeBracedClsid", StringComparison.Ordinal), + "BenchmarkSemanticsDefinesRegistryScannerLocationHelpers" +); + +AssertTrue( + benchmarkSemanticsSource.Contains("public static class StaticVerb", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("UniqueKeySeparator = '|'", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("IconValueName = \"Icon\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static bool IsIgnoredStaticVerbName", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static string BuildStaticVerbUniqueKey", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static bool TryParseStaticVerbUniqueKey", StringComparison.Ordinal), + "BenchmarkSemanticsDefinesStaticVerbKeyHelpers" +); + +AssertTrue( + benchmarkSemanticsSource.Contains("public static class IconSource", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("ManifestAppLogo = \"ManifestAppLogo\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static class IconLocation", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("HintSeparator = '|'", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("MsAppxUriPrefix = \"ms-appx://\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("IndirectStringPrefix = \"@\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("MrtPreferredTargetSizeToken = \"targetsize-48\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("MrtPreferredScaleToken = \"scale-200\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("IconResourceIndexSeparator = ','", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("IndirectStringBufferSize = 1024", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("public static class IconFileExtension", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("Png = \".png\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("Jpg = \".jpg\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("Bmp = \".bmp\"", StringComparison.Ordinal) + && benchmarkSemanticsSource.Contains("Ico = \".ico\"", StringComparison.Ordinal), + "BenchmarkSemanticsDefinesIconSourceMarkers" +); + +AssertTrue( + packageManifestSemanticsSource.Contains("public static class PackageManifestSemantics", StringComparison.Ordinal) + && packageManifestSemanticsSource.Contains("FileName = \"AppxManifest.xml\"", StringComparison.Ordinal) + && packageManifestSemanticsSource.Contains("ContextMenuCategoryToken = \"fileExplorerContextMenus\"", StringComparison.Ordinal) + && packageManifestSemanticsSource.Contains("ContextMenuCategory = \"windows.fileExplorerContextMenus\"", StringComparison.Ordinal), + "PackageManifestSemanticsDefinesManifestTokens" +); + +AssertTrue( + comRegistrySemanticsSource.Contains("public static class ComRegistrySemantics", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("FriendlyNameValueName = \"FriendlyName\"", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("InprocServer32SubKeyName = \"InprocServer32\"", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("ClsidPrefix = \"CLSID\"", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("Wow6432NodeClsidPrefix = @\"WOW6432Node\\CLSID\"", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("BuildPackagedComClassIndexPath", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("BuildClsidPath", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("BuildWow6432NodeClsidPath", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("BuildPackageRepositoryPath", StringComparison.Ordinal) + && comRegistrySemanticsSource.Contains("ExtractPackageIdPrefix", StringComparison.Ordinal), + "ComRegistrySemanticsDefinesRegistryMetadataTokens" +); + +AssertTrue( + registryScannerSource.Contains("BenchmarkSemantics.RegistryLocationLabel.AllFiles", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.BuildDisabledRegistryLocationLabel", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.BuildExtensionRegistryLocationLabel", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.BuildProgIdRegistryLocationLabel", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.BuildRegistryHandlerLocation", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.AllFilesHandlers", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.BuildSystemFileAssociationHandlers", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.BuildProgIdHandlers", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.AllFilesShell", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.BuildSystemFileAssociationShell", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.BuildProgIdShell", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.DirectoryAssociationType", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.RegistryToken.ExtensionPrefix", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.LooksLikeBracedClsid", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.IconLocation.IndirectStringPrefix", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.IsIgnoredStaticVerbName(verbName)", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.StaticVerb.CommandSubKeyName", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.StaticVerb.MuiVerbValueName", StringComparison.Ordinal) + && registryScannerSource.Contains("BenchmarkSemantics.BuildStaticVerbUniqueKey(displayName, command)", StringComparison.Ordinal), + "RegistryScannerUsesSemanticLocationBuilders" +); + +AssertTrue( + !registryScannerSource.Contains("\"All Files (*)\"", StringComparison.Ordinal) + && !registryScannerSource.Contains("\"Directory [Disabled]\"", StringComparison.Ordinal) + && !registryScannerSource.Contains("\"Extension (", StringComparison.Ordinal) + && !registryScannerSource.Contains("\"ProgID (", StringComparison.Ordinal) + && !registryScannerSource.Contains("\"directory\"", StringComparison.Ordinal) + && !registryScannerSource.Contains("@\"*\\shellex\\ContextMenuHandlers\"", StringComparison.Ordinal) + && !registryScannerSource.Contains("@\"Directory\\shell\"", StringComparison.Ordinal) + && !registryScannerSource.Contains("SystemFileAssociations\\", StringComparison.Ordinal) + && !registryScannerSource.Contains("keyName.StartsWith(\".\")", StringComparison.Ordinal) + && !registryScannerSource.Contains("trimmedName.StartsWith(\"{\")", StringComparison.Ordinal) + && !registryScannerSource.Contains("trimmedName.EndsWith(\"}\")", StringComparison.Ordinal) + && !registryScannerSource.Contains("trimmedGuid.StartsWith(\"{\")", StringComparison.Ordinal) + && !registryScannerSource.Contains("displayName.StartsWith(\"@\")", StringComparison.Ordinal) + && !registryScannerSource.Contains("verbName.Equals(\"Attributes\"", StringComparison.Ordinal) + && !registryScannerSource.Contains("verbName.Equals(\"AnyCode\"", StringComparison.Ordinal), + "RegistryScannerNoInlineLocationLabelLiterals" +); + +AssertTrue( + extensionManagerSource.Contains("BenchmarkSemantics.RegistryLocationToken.StaticVerbDisabledKeyPrefix", StringComparison.Ordinal) + && !extensionManagerSource.Contains("keyName.StartsWith(\"-\")", StringComparison.Ordinal) + && !extensionManagerSource.Contains("\"-\" + keyName", StringComparison.Ordinal) + && !extensionManagerSource.Contains("keyName.Substring(1)", StringComparison.Ordinal), + "ExtensionManagerUsesDisabledPrefixSemantics" +); + +AssertTrue( + shellUtilsSource.Contains("BenchmarkSemantics.IconLocation.IndirectStringPrefix", StringComparison.Ordinal) + && shellUtilsSource.Contains("BenchmarkSemantics.IconLocation.IndirectStringBufferSize", StringComparison.Ordinal) + && shellUtilsSource.Contains("ComRegistrySemantics.BuildClsidPath", StringComparison.Ordinal) + && shellUtilsSource.Contains("ComRegistrySemantics.BuildWow6432NodeClsidPath", StringComparison.Ordinal) + && !shellUtilsSource.Contains("StartsWith(\"@\")", StringComparison.Ordinal) + && !shellUtilsSource.Contains("new StringBuilder(1024)", StringComparison.Ordinal) + && !shellUtilsSource.Contains("OpenSubKey($@\"CLSID\\", StringComparison.Ordinal) + && !shellUtilsSource.Contains("OpenSubKey($@\"WOW6432Node\\CLSID\\", StringComparison.Ordinal), + "ShellUtilsUsesSemanticResourceAndClsidHelpers" +); + +AssertTrue( + packageScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.AnyAssociationType", StringComparison.Ordinal) + && packageScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.IsDirectoryLikeAssociationType", StringComparison.Ordinal) + && packageScannerSource.Contains("BenchmarkSemantics.RegistryPathPattern.DirectoryAssociationType", StringComparison.Ordinal), + "PackageScannerUsesAssociationTypeSemantics" +); + +AssertTrue( + packageScannerSource.Contains("PackageManifestSemantics.Manifest.FileName", StringComparison.Ordinal) + && packageScannerSource.Contains("PackageManifestSemantics.Manifest.ContextMenuCategoryToken", StringComparison.Ordinal) + && packageScannerSource.Contains("PackageManifestSemantics.Manifest.ContextMenuCategory", StringComparison.Ordinal) + && packageScannerSource.Contains("PackageManifestSemantics.Manifest.ExtensionElement", StringComparison.Ordinal) + && packageScannerSource.Contains("PackageManifestSemantics.Manifest.CategoryAttribute", StringComparison.Ordinal) + && packageScannerSource.Contains("ComRegistrySemantics.BuildPackagedComClassIndexPath", StringComparison.Ordinal), + "PackageScannerUsesPackageManifestSemantics" +); + +AssertTrue( + !packageScannerSource.Contains("\"directory\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("\"folder\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("\"directory\\\\background\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("type == \"*\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("\"AppxManifest.xml\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("\"fileExplorerContextMenus\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("\"windows.fileExplorerContextMenus\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("Name.LocalName == \"Extension\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("Attribute(\"Category\")", StringComparison.Ordinal) + && !packageScannerSource.Contains("OpenSubKey($@\"PackagedCom\\\\ClassIndex\\\\{clsid:B}\")", StringComparison.Ordinal), + "PackageScannerNoInlineAssociationTypeLiterals" +); + +AssertTrue( + packageScannerSource.Contains("BenchmarkSemantics.IconSource.ManifestAppLogo", StringComparison.Ordinal) + && packageScannerSource.Contains("BenchmarkSemantics.IconLocation.HintSeparator", StringComparison.Ordinal) + && packageScannerSource.Contains("BenchmarkSemantics.IconLocation.MsAppxUriPrefix", StringComparison.Ordinal) + && !packageScannerSource.Contains("\"Manifest (App Logo)\"", StringComparison.Ordinal) + && !packageScannerSource.Contains("\"None\"", StringComparison.Ordinal), + "PackageScannerUsesIconSourceSemantics" +); + +AssertTrue( + nullOrEmptyConverterSource.Contains("BenchmarkSemantics.IconSource.ManifestAppLogo", StringComparison.Ordinal) + && nullOrEmptyConverterSource.Contains("Dashboard.Value.ManifestAppLogo", StringComparison.Ordinal), + "NullOrEmptyConverterMapsIconSourceMarkerLocalization" +); + +AssertTrue( + iconToImageConverterSource.Contains("BenchmarkSemantics.IconLocation.HintSeparator", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconLocation.MsAppxUriPrefix", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconLocation.IndirectStringPrefix", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconLocation.MrtPreferredTargetSizeToken", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconLocation.MrtPreferredScaleToken", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconLocation.IconResourceIndexSeparator", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconLocation.IndirectStringBufferSize", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconFileExtension.Png", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconFileExtension.Jpg", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("BenchmarkSemantics.IconFileExtension.Bmp", StringComparison.Ordinal) + && iconToImageConverterSource.Contains("HookIpcSemantics.Response.NoIconToken", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("uriStr.IndexOf('|')", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("path == \"NONE\"", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("path.StartsWith(\"@\")", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("path.LastIndexOf(',')", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("path.StartsWith(\"ms-appx://\")", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("targetsize-48", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("scale-200", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("new StringBuilder(1024)", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("ext == \".png\"", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("ext == \".jpg\"", StringComparison.Ordinal) + && !iconToImageConverterSource.Contains("ext == \".bmp\"", StringComparison.Ordinal), + "IconToImageConverterUsesIconProtocolSemantics" +); + +AssertTrue( + !benchmarkSemanticsSource.Contains("bool hasDrive = false;", StringComparison.Ordinal) + && !benchmarkSemanticsSource.Contains("bool hasFolder = false;", StringComparison.Ordinal) + && !benchmarkSemanticsSource.Contains("bool hasFile = false;", StringComparison.Ordinal), + "BenchmarkSemanticsNoLegacyLocationFlagResolution" +); + +AssertTrue( + benchmarkStatisticsSource.Contains("public static class BenchmarkStatisticsCalculator", StringComparison.Ordinal) + && benchmarkStatisticsSource.Contains("public static BenchmarkStatistics Calculate", StringComparison.Ordinal), + "BenchmarkStatisticsCalculatorExists" +); + +AssertTrue( + hookIpcSemanticsSource.Contains("VersionPrefix = \"CMP1\"", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("ModeAuto = \"AUTO\"", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("PipeName = \"ContextMenuProfilerHook\"", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("ProbeFileName = \"ContextMenuProfiler_probe.txt\"", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("ProbeFileContent = \"probe\"", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("FrameHeaderBytes = 4", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("MaxRequestBytes = 16384", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("MaxResponseBytes = 65536", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("MultiValueDelimiter = '|'", StringComparison.Ordinal) + && hookIpcSemanticsSource.Contains("NoIconToken = \"NONE\"", StringComparison.Ordinal), + "HookIpcSemanticsDefinesProtocolAndPipeConstants" +); + +AssertTrue( + hookIpcClientSource.Contains("HookIpcSemantics.Runtime.MaxConcurrentCalls", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Runtime.MaxAttempts", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Runtime.RetryDelayMs", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Protocol.VersionPrefix", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Runtime.ProbeFileName", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Runtime.ProbeFileContent", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Runtime.FrameHeaderBytes", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Runtime.MaxRequestBytes", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Runtime.MaxResponseBytes", StringComparison.Ordinal) + && hookIpcClientSource.Contains("HookIpcSemantics.Response.MultiValueDelimiter", StringComparison.Ordinal) + && hookIpcClientSource.Contains("WriteFrameAsync", StringComparison.Ordinal) + && hookIpcClientSource.Contains("ReadFrameAsync", StringComparison.Ordinal) + && hookIpcClientSource.Contains("ReadExactAsync", StringComparison.Ordinal) + && hookIpcClientSource.Contains("ShouldRetry(attempt)", StringComparison.Ordinal) + && hookIpcClientSource.Contains("private static async Task DelayForRetryAsync", StringComparison.Ordinal) + && hookIpcClientSource.Contains("await DelayForRetryAsync(attempt)", StringComparison.Ordinal) + && hookIpcClientSource.Contains("private static void CompleteRoundTrip", StringComparison.Ordinal) + && hookIpcClientSource.Contains("TryProbeOnceAsync", StringComparison.Ordinal), + "HookIpcClientUsesIpcSemanticsConstants" +); + +AssertTrue( + !hookIpcClientSource.Contains("private const string ProtocolPrefix", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("private const string ProtocolMode", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("private const string PipeName", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("private const int ConnectTimeoutMs", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("private const int RoundTripTimeoutMs", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("Task.Delay(80)", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("attempt == 0", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("ContextMenuProfiler_probe.txt", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("new StringBuilder(1024)", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("new byte[4096]", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("ExtractJsonEnvelope(", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("ReadResponseAsync(", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("client.IsMessageComplete", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("if (ShouldRetry(attempt))", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("data.names.Split('|',", StringComparison.Ordinal) + && !hookIpcClientSource.Contains("result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds);\r\n if (await DelayForRetryAsync(attempt))", StringComparison.Ordinal), + "HookIpcClientNoLegacyInlineProtocolRuntimeLiterals" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.Runtime.MaxParallelProbeTasks", StringComparison.Ordinal), + "BenchmarkServiceUsesMaxParallelProbeTasksConstant" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.Runtime.RealShellBenchmarkUnsupportedMs", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("=> -1", StringComparison.Ordinal), + "BenchmarkServiceUsesRealShellUnsupportedSentinelConstant" +); + +AssertTrue( + benchmarkServiceSource.Contains("LocalizationService.Instance[\"Dashboard.Value.UnknownWithClsid\"]", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("Unknown (", StringComparison.Ordinal), + "BenchmarkServiceUsesLocalizedUnknownWithClsidName" +); + +AssertTrue( + dashboardViewModelSource.Contains("BenchmarkSemantics.Runtime.HookReconnectStabilizationDelayMs", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("BenchmarkSemantics.Runtime.ClipboardRetryAttempts", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("BenchmarkSemantics.Runtime.ClipboardRetryDelayMs", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("BenchmarkSemantics.Runtime.ClipboardCantOpenHResult", StringComparison.Ordinal), + "DashboardViewModelUsesRuntimeRetryTimingConstants" +); + +AssertTrue( + !dashboardViewModelSource.Contains("Task.Delay(1000)", StringComparison.Ordinal) + && !dashboardViewModelSource.Contains("for (int i = 0; i < 5; i++)", StringComparison.Ordinal) + && !dashboardViewModelSource.Contains("Task.Delay(100)", StringComparison.Ordinal) + && !dashboardViewModelSource.Contains("0x800401D0", StringComparison.Ordinal), + "DashboardViewModelNoInlineRetryTimingLiterals" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.Runtime.IpcTimeoutLikeRoundtripThresholdMs", StringComparison.Ordinal), + "BenchmarkServiceUsesIpcTimeoutThresholdConstant" +); + +AssertTrue( + benchmarkServiceSource.Contains("HookIpcSemantics.Response.MultiValueDelimiter", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("HookIpcSemantics.Response.NoIconToken", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("BenchmarkSemantics.IconLocation.IconResourceIndexSeparator", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("BenchmarkSemantics.IconFileExtension.Ico", StringComparison.Ordinal), + "BenchmarkServiceUsesHookIpcResponseSemantics" +); + +AssertTrue( + !benchmarkServiceSource.Contains("hookData.icons.Split('|')", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("i != \"NONE\"", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("hookData.reg_icon.Contains(\",\")", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("EndsWith(\".ico\")", StringComparison.Ordinal), + "BenchmarkServiceNoLegacyHookResponseDelimiterLiterals" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.TryParseStaticVerbUniqueKey(key, out string name, out string command)", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("private static bool TryParseStaticVerbKey", StringComparison.Ordinal), + "BenchmarkServiceUsesStaticVerbKeyParser" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.StaticVerb.IconValueName", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("GetValue(\"Icon\")", StringComparison.Ordinal), + "BenchmarkServiceUsesStaticVerbIconValueSemantic" +); + +AssertTrue( + benchmarkServiceSource.Contains("ComRegistrySemantics.FriendlyNameValueName", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("ComRegistrySemantics.InprocServer32SubKeyName", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("ComRegistrySemantics.BuildPackagedComClassIndexPath", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("ComRegistrySemantics.BuildPackageRepositoryPath", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("ComRegistrySemantics.ExtractPackageIdPrefix", StringComparison.Ordinal), + "BenchmarkServiceUsesComRegistrySemantics" +); + +AssertTrue( + !benchmarkServiceSource.Contains("key.GetValue(\"FriendlyName\")", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("OpenSubKey(\"InprocServer32\")", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("OpenSubKey(\"TreatAs\")", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("key.GetValue(\"AppID\")", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("GetValue(\"DllSurrogate\")", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("\"dllhost.exe\"", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("@\"PackagedCom\\ClassIndex\\", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("@\"PackagedCom\\Package\"", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("GetValue(\"DisplayName\")", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("Split('_')[0]", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("GetValue(\"DllPath\")", StringComparison.Ordinal), + "BenchmarkServiceNoInlineComRegistryMetadataLiterals" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.BuildStaticVerbRegistryLocation(p)", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("paths.Any(BenchmarkSemantics.IsStaticVerbRegistryPathDisabled)", StringComparison.Ordinal), + "BenchmarkServiceUsesStaticVerbRegistrySemanticHelpers" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.IsTimeoutLikeError(hookData.error)", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("hookData.error.Contains(\"Timeout\"", StringComparison.Ordinal), + "BenchmarkServiceUsesTimeoutErrorSemanticHelper" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.IsSkipUnstableHandlersEnabled()", StringComparison.Ordinal) + && benchmarkServiceSource.Contains("BenchmarkSemantics.ContainsKnownUnstableHandlerToken", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("CMP_SKIP_UNSTABLE_HANDLERS", StringComparison.Ordinal), + "BenchmarkServiceUsesUnstableHandlerSemanticHelpers" +); + +AssertTrue( + !benchmarkServiceSource.Contains("PintoStartScreen", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("NvcplDesktopContext", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("NvAppDesktopContext", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("NVIDIA CPL Context Menu Extension", StringComparison.Ordinal), + "BenchmarkServiceNoInlineUnstableHandlerTokens" +); + +AssertTrue( + !benchmarkServiceSource.Contains("Registry (Shell) -", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("p.Split('\\\\')[0]", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("p.Split('\\\\').Last().StartsWith(\"-\")", StringComparison.Ordinal), + "BenchmarkServiceNoInlineStaticVerbRegistryLiterals" +); + +AssertTrue( + !benchmarkServiceSource.Contains("new SemaphoreSlim(8)", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("hookCall.roundtrip_ms >= 1900", StringComparison.Ordinal) + && !benchmarkServiceSource.Contains("key.Split('|')[1]", StringComparison.Ordinal), + "BenchmarkServiceNoRuntimeMagicNumericLiterals" +); + +string[] forbiddenStatusMagicLiterals = +{ + BenchmarkSemantics.GetStatusDisplayText(BenchmarkSemantics.Status.RegistryFallback), + BenchmarkSemantics.GetStatusDisplayText(BenchmarkSemantics.Status.LoadError), + BenchmarkSemantics.GetStatusDisplayText(BenchmarkSemantics.Status.OrphanedMissingDll), + BenchmarkSemantics.GetStatusDisplayText(BenchmarkSemantics.Status.IpcTimeout), + BenchmarkSemantics.GetStatusDisplayText(BenchmarkSemantics.Status.VerifiedViaHook), + BenchmarkSemantics.GetStatusDisplayText(BenchmarkSemantics.Status.HookLoadedNoMenu), + BenchmarkSemantics.GetStatusDisplayText(BenchmarkSemantics.Status.SkippedKnownUnstable) +}; + +AssertSourceDoesNotContainAny(benchmarkServiceSource, "BenchmarkServiceNoStatusMagicLiterals", forbiddenStatusMagicLiterals); +AssertSourceDoesNotContainAny(packageScannerSource, "PackageScannerNoStatusMagicLiterals", forbiddenStatusMagicLiterals); +AssertSourceDoesNotContainAny(dashboardViewModelSource, "DashboardViewModelNoStatusMagicLiterals", forbiddenStatusMagicLiterals); +AssertSourceDoesNotContainAny(statusVisibilityConverterSource, "StatusVisibilityConverterNoStatusMagicLiterals", forbiddenStatusMagicLiterals); + +string[] forbiddenDetailedStatusLiterals = +{ + resources["en-US"]["Dashboard.Detail.StaticNotMeasured"], + resources["en-US"]["Dashboard.Detail.SkippedKnownUnstable"], + resources["en-US"]["Dashboard.Detail.HookLoadedNoMenu"], + resources["en-US"]["Dashboard.Detail.HookResponseTimeoutFallback"], + resources["en-US"]["Dashboard.Detail.HookUnavailableFallback"] +}; + +AssertSourceDoesNotContainAny( + benchmarkServiceSource, + "BenchmarkServiceNoHardcodedDetailedStatusLiterals", + forbiddenDetailedStatusLiterals +); + +string[] requiredDetailLocalizationKeysInBenchmarkService = +{ + "Dashboard.Detail.StaticNotMeasured", + "Dashboard.Detail.SkippedKnownUnstable", + "Dashboard.Detail.OrphanedMissingDll", + "Dashboard.Detail.HookLoadedNoMenu", + "Dashboard.Detail.HookProbeTimeoutWithError", + "Dashboard.Detail.HookLoadErrorWithError", + "Dashboard.Detail.HookResponseTimeoutFallback", + "Dashboard.Detail.HookUnavailableFallback" +}; + +foreach (var key in requiredDetailLocalizationKeysInBenchmarkService) +{ + AssertTrue( + benchmarkServiceSource.Contains($"[\"{key}\"]", StringComparison.Ordinal), + $"BenchmarkServiceUsesLocalizedDetailKey:{key}" + ); +} + +string[] forbiddenInterfaceAndLocationLiterals = +{ + "Static Verb", + "Skipped", + "Modern Shell (UWP)", + "[Disabled]" +}; + +AssertSourceDoesNotContainAny( + benchmarkServiceSource, + "BenchmarkServiceNoInterfaceLocationMagicLiterals", + forbiddenInterfaceAndLocationLiterals +); + +AssertTrue( + !dashboardViewModelSource.Contains("private static class CategoryTag", StringComparison.Ordinal), + "DashboardViewModelNoCategoryTagDuplication" +); + +AssertTrue( + dashboardViewModelSource.Contains("BenchmarkSemantics.IsCategoryMatch", StringComparison.Ordinal), + "DashboardViewModelUsesCentralizedCategoryMatch" +); + +AssertTrue( + dashboardViewModelSource.Contains("BenchmarkSemantics.IsPackagedExtensionType(item.Type)", StringComparison.Ordinal), + "DashboardViewModelUsesPackagedTypeSemanticHelper" +); + +AssertTrue( + dashboardViewModelSource.Contains("BenchmarkStatisticsCalculator.Calculate(Results)", StringComparison.Ordinal), + "DashboardViewModelUsesStatisticsCalculator" +); + +AssertTrue( + !dashboardViewModelSource.Contains("foreach (var r in Results)", StringComparison.Ordinal), + "DashboardViewModelNoManualStatsAggregationLoop" +); + +AssertTrue( + !dashboardViewModelSource.Contains("string.Equals(item.Type, BenchmarkSemantics.Type.Uwp", StringComparison.Ordinal), + "DashboardViewModelNoUwpOnlyDeleteCheck" +); + +AssertTrue( + benchmarkServiceSource.Contains("BenchmarkSemantics.IsRegistryManagedExtensionType(result.Type)", StringComparison.Ordinal), + "BenchmarkServiceUsesRegistryManagedSemanticHelper" +); + +AssertTrue( + typeToIconConverterSource.Contains("BenchmarkSemantics.IsPackagedExtensionType(type)", StringComparison.Ordinal), + "TypeToIconConverterUsesPackagedTypeSemanticHelper" +); + +AssertTrue( + dashboardViewModelSource.Contains("BeginScanSession(", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("LastScanMode.System", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("LastScanMode.File", StringComparison.Ordinal), + "DashboardViewModelUsesScanSessionInitializer" +); + +AssertTrue( + dashboardViewModelSource.Contains("private async Task> RunFileBenchmarkInStaAsync", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("RunFileBenchmarkInStaAsync(filePath)", StringComparison.Ordinal), + "DashboardViewModelUsesStaFileBenchmarkHelper" +); + +AssertTrue( + dashboardViewModelSource.Contains("private void NotifyScanComplete", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("NotifyScanComplete(Results.Count)", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("NotifyScanComplete(results.Count, filePath)", StringComparison.Ordinal), + "DashboardViewModelUsesScanCompleteHelper" +); + +AssertTrue( + dashboardViewModelSource.Contains("private void HandleScanFailure", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("HandleScanFailure(\"Scan System Failed\"", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("HandleScanFailure(\"File Scan Failed\"", StringComparison.Ordinal), + "DashboardViewModelUsesScanFailureHelper" +); + +AssertTrue( + registryPathHelperSource.Contains("public static class RegistryPathHelper", StringComparison.Ordinal) + && registryPathHelperSource.Contains("NormalizeForRegedit", StringComparison.Ordinal) + && registryPathHelperSource.Contains("ClassesRootPrefix", StringComparison.Ordinal), + "RegistryPathHelperExists" +); + +AssertTrue( + dashboardViewModelSource.Contains("RegistryPathHelper.NormalizeForRegedit(path)", StringComparison.Ordinal), + "DashboardViewModelUsesRegistryPathHelper" +); + +AssertTrue( + !dashboardViewModelSource.Contains("path.StartsWith(\"*\\\\\")", StringComparison.Ordinal) + && !dashboardViewModelSource.Contains("HKEY_CLASSES_ROOT\\\\", StringComparison.Ordinal), + "DashboardViewModelNoInlineRegistryPathNormalization" +); + +AssertTrue( + dashboardViewModelSource.Contains("class DashboardViewModel : ObservableObject, IDisposable", StringComparison.Ordinal), + "DashboardViewModelImplementsDisposable" +); + +AssertTrue( + dashboardViewModelSource.Contains("if (token.IsCancellationRequested || _disposed) return;", StringComparison.Ordinal), + "DashboardViewModelGuardsFilterUpdatesAfterDispose" +); + +AssertTrue( + dashboardViewModelSource.Contains("private void OnLocalizationChanged", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("private void OnHookServicePropertyChanged", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("if (_disposed)", StringComparison.Ordinal), + "DashboardViewModelGuardsEventHandlersAfterDispose" +); + +AssertTrue( + dashboardViewModelSource.Contains("LocalizationService.Instance.PropertyChanged += _localizationChangedHandler", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("LocalizationService.Instance.PropertyChanged -= _localizationChangedHandler", StringComparison.Ordinal), + "DashboardViewModelLocalizationSubscriptionBalanced" +); + +AssertTrue( + dashboardViewModelSource.Contains("HookService.Instance.PropertyChanged += _hookServiceChangedHandler", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("HookService.Instance.PropertyChanged -= _hookServiceChangedHandler", StringComparison.Ordinal), + "DashboardViewModelHookSubscriptionBalanced" +); + +AssertTrue( + dashboardViewModelSource.Contains("_filterCts?.Dispose();", StringComparison.Ordinal) + && dashboardViewModelSource.Contains("public void Dispose()", StringComparison.Ordinal), + "DashboardViewModelDisposesFilterCts" +); + +string[] forbiddenLegacyStatusConverterBindings = +{ + "StatusToVisibilityConverter}, ConverterParameter=NotActive", + "StatusToVisibilityConverter}, ConverterParameter=Fallback", + "StatusToVisibilityConverter}, ConverterParameter=NotUWP", + "StatusToVisibilityConverter}, ConverterParameter=Inverse" +}; + +foreach (var legacyBinding in forbiddenLegacyStatusConverterBindings) +{ + AssertTrue( + !dashboardPageXamlSource.Contains(legacyBinding, StringComparison.Ordinal), + $"DashboardPageNoLegacyStatusConverterBinding:{legacyBinding}" + ); +} + +AssertTrue( + dashboardPageXamlSource.Contains("StatusVisibilityMode.NotActive", StringComparison.Ordinal) + && dashboardPageXamlSource.Contains("StatusVisibilityMode.Fallback", StringComparison.Ordinal) + && dashboardPageXamlSource.Contains("StatusVisibilityMode.NotPackaged", StringComparison.Ordinal), + "DashboardPageUsesTypedStatusVisibilityModes" +); + +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 => r.Status == BenchmarkSemantics.Status.RegistryFallback); + 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/Converters/IconToImageConverter.cs b/ContextMenuProfiler.UI/Converters/IconToImageConverter.cs index 202260d..a34610a 100644 --- a/ContextMenuProfiler.UI/Converters/IconToImageConverter.cs +++ b/ContextMenuProfiler.UI/Converters/IconToImageConverter.cs @@ -14,6 +14,7 @@ using System.Linq; using System.Collections.Concurrent; +using ContextMenuProfiler.UI.Core; namespace ContextMenuProfiler.UI.Converters { @@ -29,7 +30,7 @@ public class IconToImageConverter : IValueConverter // 解析 URI 和可选的 Binary Hint string actualUri = uriStr; string? hintDllPath = null; - int pipeIndex = uriStr.IndexOf('|'); + int pipeIndex = uriStr.IndexOf(BenchmarkSemantics.IconLocation.HintSeparator); if (pipeIndex > 0) { actualUri = uriStr.Substring(0, pipeIndex); hintDllPath = uriStr.Substring(pipeIndex + 1); @@ -72,8 +73,10 @@ public class IconToImageConverter : IValueConverter string fileName = Path.GetFileNameWithoutExtension(fullPath); string ext = Path.GetExtension(fullPath); var files = Directory.GetFiles(dir, $"{fileName}*{ext}"); - var best = files.OrderByDescending(f => f.Contains("targetsize-48")) - .ThenByDescending(f => f.Contains("scale-200")) + var best = files.OrderByDescending(f => + f.Contains(BenchmarkSemantics.IconLocation.MrtPreferredTargetSizeToken, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(f => + f.Contains(BenchmarkSemantics.IconLocation.MrtPreferredScaleToken, StringComparison.OrdinalIgnoreCase)) .FirstOrDefault(); if (best != null) return best; } @@ -88,7 +91,11 @@ public object Convert(object value, Type targetType, object parameter, CultureIn { string? path = value as string; - if (string.IsNullOrEmpty(path) || path == "NONE") return DependencyProperty.UnsetValue; + if (string.IsNullOrEmpty(path) + || string.Equals(path, HookIpcSemantics.Response.NoIconToken, StringComparison.OrdinalIgnoreCase)) + { + return DependencyProperty.UnsetValue; + } if (_iconCache.TryGetValue(path, out var cached)) return cached; @@ -112,7 +119,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn try { // Handle ms-appx:// URIs (UWP resources) - if (path.StartsWith("ms-appx://")) + if (path.StartsWith(BenchmarkSemantics.IconLocation.MsAppxUriPrefix, StringComparison.OrdinalIgnoreCase)) { var resolvedPath = ResolveMsAppxUri(path); if (string.IsNullOrEmpty(resolvedPath)) return null; @@ -123,9 +130,9 @@ public object Convert(object value, Type targetType, object parameter, CultureIn path = Environment.ExpandEnvironmentVariables(path); // Handle MUI / UWP Resource strings (starts with @) - if (path.StartsWith("@")) + if (path.StartsWith(BenchmarkSemantics.IconLocation.IndirectStringPrefix, StringComparison.Ordinal)) { - StringBuilder sb = new StringBuilder(1024); + StringBuilder sb = new StringBuilder(BenchmarkSemantics.IconLocation.IndirectStringBufferSize); int res = SHLoadIndirectString(path, sb, (uint)sb.Capacity, IntPtr.Zero); if (res == 0) // S_OK { @@ -143,7 +150,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn else { // Remove @ prefix and try parsing as normal path - path = path.Substring(1); + path = path.Substring(BenchmarkSemantics.IconLocation.IndirectStringPrefix.Length); } } @@ -151,7 +158,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn string filePath = path; // Parse resource index (path,index or path,-id) - int commaIndex = path.LastIndexOf(','); + int commaIndex = path.LastIndexOf(BenchmarkSemantics.IconLocation.IconResourceIndexSeparator); if (commaIndex > 0) { string indexStr = path.Substring(commaIndex + 1); @@ -176,7 +183,9 @@ public object Convert(object value, Type targetType, object parameter, CultureIn { string ext = Path.GetExtension(filePath).ToLower(); - if (ext == ".png" || ext == ".jpg" || ext == ".bmp") + if (ext == BenchmarkSemantics.IconFileExtension.Png + || ext == BenchmarkSemantics.IconFileExtension.Jpg + || ext == BenchmarkSemantics.IconFileExtension.Bmp) { var bitmap = new BitmapImage(); bitmap.BeginInit(); diff --git a/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs index d533dd9..320c5a3 100644 --- a/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs +++ b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs @@ -1,6 +1,8 @@ using System; using System.Globalization; using System.Windows.Data; +using ContextMenuProfiler.UI.Core; +using ContextMenuProfiler.UI.Core.Services; namespace ContextMenuProfiler.UI.Converters { @@ -8,35 +10,36 @@ 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; if (values[0] is int i) ms = i; - string status = values[1]?.ToString() ?? string.Empty; - - if (ShouldShowNa(status, ms)) + if (ShouldShowNa(values[1], ms)) { - return "N/A"; + return noneText; } return $"{ms} ms"; } - private static bool ShouldShowNa(string status, long ms) + private static bool ShouldShowNa(object? statusValue, long ms) { if (ms > 0) return false; - if (string.IsNullOrWhiteSpace(status)) return true; + if (statusValue is BenchmarkStatus status) + { + return BenchmarkSemantics.IsFallbackLikeStatus(status) + || BenchmarkSemantics.IsNotMeasuredLikeStatus(status); + } + + string statusText = statusValue?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(statusText)) return true; - return status.Contains("Fallback", StringComparison.OrdinalIgnoreCase) || - status.Contains("Load Error", StringComparison.OrdinalIgnoreCase) || - status.Contains("Orphaned", StringComparison.OrdinalIgnoreCase) || - status.Contains("Missing", StringComparison.OrdinalIgnoreCase) || - status.Contains("Not Measured", StringComparison.OrdinalIgnoreCase) || - status.Contains("Unsupported", StringComparison.OrdinalIgnoreCase) || - status.Contains("No Menu", StringComparison.OrdinalIgnoreCase); + return BenchmarkSemantics.IsFallbackLikeStatus(statusText) + || BenchmarkSemantics.IsNotMeasuredLikeStatus(statusText); } public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) diff --git a/ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs b/ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs index 1601879..4f465ce 100644 --- a/ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs +++ b/ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs @@ -1,3 +1,4 @@ +using ContextMenuProfiler.UI.Core; using ContextMenuProfiler.UI.Core.Services; using System; using System.Globalization; @@ -12,6 +13,11 @@ public object Convert(object value, Type targetType, object parameter, CultureIn string text = value?.ToString() ?? string.Empty; if (!string.IsNullOrWhiteSpace(text)) { + if (string.Equals(text, BenchmarkSemantics.IconSource.ManifestAppLogo, StringComparison.Ordinal)) + { + return LocalizationService.Instance["Dashboard.Value.ManifestAppLogo"]; + } + return text; } diff --git a/ContextMenuProfiler.UI/Converters/StatusToLocalizedTextConverter.cs b/ContextMenuProfiler.UI/Converters/StatusToLocalizedTextConverter.cs new file mode 100644 index 0000000..6cfb01c --- /dev/null +++ b/ContextMenuProfiler.UI/Converters/StatusToLocalizedTextConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using ContextMenuProfiler.UI.Core; + +namespace ContextMenuProfiler.UI.Converters +{ + public class StatusToLocalizedTextConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is BenchmarkStatus status) + { + return BenchmarkSemantics.GetLocalizedStatusText(status); + } + + if (value is string statusText && BenchmarkSemantics.TryParseStatus(statusText, out BenchmarkStatus parsedStatus)) + { + return BenchmarkSemantics.GetLocalizedStatusText(parsedStatus); + } + + return BenchmarkSemantics.GetLocalizedStatusText(BenchmarkStatus.Unknown); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs b/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs index a07f197..8cb7c2c 100644 --- a/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs +++ b/ContextMenuProfiler.UI/Converters/StatusToVisibilityConverter.cs @@ -2,57 +2,92 @@ using System.Globalization; using System.Windows; using System.Windows.Data; +using ContextMenuProfiler.UI.Core; using ContextMenuProfiler.UI.Core.Services; namespace ContextMenuProfiler.UI.Converters { + public enum StatusVisibilityMode + { + Default, + NotActive, + NotPackaged, + NotUwp = NotPackaged, + Fallback, + Inverse + } + public class StatusToVisibilityConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - string? param = parameter as string; + StatusVisibilityMode mode = ResolveMode(parameter); - if (param == "NotActive" && value is HookStatus hookStatus) + if (mode == StatusVisibilityMode.NotActive && value is HookStatus hookStatus) { return hookStatus != HookStatus.Active ? Visibility.Visible : Visibility.Collapsed; } - if (param == "NotUWP") + if (mode == StatusVisibilityMode.NotPackaged) { string? type = value as string; - return (type != "UWP" && type != "UWP / Packaged COM") ? Visibility.Visible : Visibility.Collapsed; + return BenchmarkSemantics.IsRegistryManagedExtensionType(type) ? Visibility.Visible : Visibility.Collapsed; } - if (param == "Fallback") + if (mode == StatusVisibilityMode.Fallback) { - string? statusStr = value as string; - bool isFallback = statusStr != null && (statusStr.Contains("Fallback") || statusStr.Contains("Error") || statusStr.Contains("Orphaned") || statusStr.Contains("Missing")); + bool isFallback = value switch + { + BenchmarkStatus statusValue => BenchmarkSemantics.IsFallbackLikeStatus(statusValue), + string statusTextValue => BenchmarkSemantics.IsFallbackLikeStatus(statusTextValue), + _ => false + }; return isFallback ? Visibility.Visible : Visibility.Collapsed; } - if (value is string status) + if (value is BenchmarkStatus 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"); - - if (param == "Inverse") + bool isWarning = BenchmarkSemantics.IsWarningLikeStatus(status); + + if (mode == StatusVisibilityMode.Inverse) { return isWarning ? Visibility.Collapsed : Visibility.Visible; } - + return isWarning ? Visibility.Visible : Visibility.Collapsed; } + + if (value is string statusText) + { + bool isWarning = BenchmarkSemantics.IsWarningLikeStatus(statusText); + + if (mode == StatusVisibilityMode.Inverse) + { + return isWarning ? Visibility.Collapsed : Visibility.Visible; + } + + return isWarning ? Visibility.Visible : Visibility.Collapsed; + } + return Visibility.Collapsed; } + private static StatusVisibilityMode ResolveMode(object? parameter) + { + if (parameter is StatusVisibilityMode typedMode) + { + return typedMode; + } + + if (parameter is string raw + && Enum.TryParse(raw, ignoreCase: true, out StatusVisibilityMode parsedMode)) + { + return parsedMode; + } + + return StatusVisibilityMode.Default; + } + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); diff --git a/ContextMenuProfiler.UI/Converters/TypeToIconConverter.cs b/ContextMenuProfiler.UI/Converters/TypeToIconConverter.cs index a81c65c..a1b1e88 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 (BenchmarkSemantics.IsPackagedExtensionType(type)) 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..be5f17c --- /dev/null +++ b/ContextMenuProfiler.UI/Core/BenchmarkSemantics.cs @@ -0,0 +1,679 @@ +using System; +using System.Collections.Generic; +using ContextMenuProfiler.UI.Core.Services; + +namespace ContextMenuProfiler.UI.Core +{ + public enum BenchmarkStatus + { + Unknown, + Ok, + VerifiedViaHook, + HookLoadedNoMenu, + OrphanedMissingDll, + IpcTimeout, + LoadError, + RegistryFallback, + StaticNotMeasured, + SkippedKnownUnstable, + DisabledPendingRestart, + EnabledPendingRestart + } + + public static class BenchmarkSemantics + { + private static readonly string[] FolderLocationHints = + { + CategoryLocationHint.Directory, + CategoryLocationHint.Folder + }; + + private static readonly string[] FileLocationHints = + { + CategoryLocationHint.AllFiles, + CategoryLocationHint.Extension, + CategoryLocationHint.AllFileSystemObjects + }; + + private static class CategoryPriority + { + public const int Unknown = 0; + public const int File = 1; + public const int Folder = 2; + public const int Drive = 3; + public const int Background = 4; + } + + public const BenchmarkStatus StatusRegistryFallback = Status.RegistryFallback; + public const BenchmarkStatus StatusHookLoadedNoMenu = Status.HookLoadedNoMenu; + public const BenchmarkStatus StatusLoadError = Status.LoadError; + public const BenchmarkStatus StatusOrphanedMissingDll = Status.OrphanedMissingDll; + + 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 FilterCategory + { + public const string All = "All"; + } + + public static class Status + { + public const BenchmarkStatus Unknown = BenchmarkStatus.Unknown; + public const BenchmarkStatus Ok = BenchmarkStatus.Ok; + public const BenchmarkStatus VerifiedViaHook = BenchmarkStatus.VerifiedViaHook; + public const BenchmarkStatus HookLoadedNoMenu = BenchmarkStatus.HookLoadedNoMenu; + public const BenchmarkStatus OrphanedMissingDll = BenchmarkStatus.OrphanedMissingDll; + public const BenchmarkStatus IpcTimeout = BenchmarkStatus.IpcTimeout; + public const BenchmarkStatus LoadError = BenchmarkStatus.LoadError; + public const BenchmarkStatus RegistryFallback = BenchmarkStatus.RegistryFallback; + public const BenchmarkStatus StaticNotMeasured = BenchmarkStatus.StaticNotMeasured; + public const BenchmarkStatus SkippedKnownUnstable = BenchmarkStatus.SkippedKnownUnstable; + public const BenchmarkStatus DisabledPendingRestart = BenchmarkStatus.DisabledPendingRestart; + public const BenchmarkStatus EnabledPendingRestart = BenchmarkStatus.EnabledPendingRestart; + } + + public static class StatusToken + { + public const string Fallback = "Fallback"; + public const string Error = "Error"; + public const string Timeout = "Timeout"; + public const string Orphaned = "Orphaned"; + public const string Missing = "Missing"; + public const string Exception = "Exception"; + public const string Failed = "Failed"; + public const string NotRegistered = "Not Registered"; + public const string Invalid = "Invalid"; + public const string NotFound = "Not Found"; + public const string NoMenu = "No Menu"; + public const string NotMeasured = "Not Measured"; + public const string Unsupported = "Unsupported"; + } + + public static class CategoryLocationHint + { + public const string Background = "Background"; + public const string Drive = "Drive"; + public const string Directory = "Directory"; + public const string Folder = "Folder"; + public const string AllFiles = "All Files"; + public const string Extension = "Extension"; + public const string AllFileSystemObjects = "All File System Objects"; + } + + public static class RegistryLocationLabel + { + public const string AllFiles = "All Files (*)"; + public const string Directory = "Directory"; + public const string Folder = "Folder"; + public const string Drive = "Drive"; + public const string AllFileSystemObjects = "All File System Objects"; + public const string DirectoryBackground = "Directory Background"; + public const string DesktopBackground = "Desktop Background"; + public const string Extension = "Extension"; + public const string ProgId = "ProgID"; + } + + public static class RegistryPathPattern + { + public const string AnyAssociationType = "*"; + public const string DirectoryAssociationType = "directory"; + public const string FolderAssociationType = "folder"; + public const string DirectoryBackgroundAssociationType = @"directory\background"; + + public const string AllFilesHandlers = @"*\shellex\ContextMenuHandlers"; + public const string AllFilesHandlersDisabled = @"*\shellex\-ContextMenuHandlers"; + public const string DirectoryHandlers = @"Directory\shellex\ContextMenuHandlers"; + public const string DirectoryHandlersDisabled = @"Directory\shellex\-ContextMenuHandlers"; + public const string FolderHandlers = @"Folder\shellex\ContextMenuHandlers"; + public const string DriveHandlers = @"Drive\shellex\ContextMenuHandlers"; + public const string AllFileSystemObjectsHandlers = @"AllFileSystemObjects\shellex\ContextMenuHandlers"; + public const string DirectoryBackgroundHandlers = @"Directory\Background\shellex\ContextMenuHandlers"; + public const string DesktopBackgroundHandlers = @"DesktopBackground\shellex\ContextMenuHandlers"; + + public const string AllFilesShell = @"*\shell"; + public const string DirectoryShell = @"Directory\shell"; + public const string DirectoryBackgroundShell = @"Directory\Background\shell"; + public const string DriveShell = @"Drive\shell"; + public const string FolderShell = @"Folder\shell"; + + public static string BuildSystemFileAssociationHandlers(string extension, bool disabled) + { + string handlerKey = disabled ? "-ContextMenuHandlers" : "ContextMenuHandlers"; + return $@"SystemFileAssociations\{extension}\shellex\{handlerKey}"; + } + + public static string BuildProgIdHandlers(string progId, bool disabled) + { + string handlerKey = disabled ? "-ContextMenuHandlers" : "ContextMenuHandlers"; + return $@"{progId}\shellex\{handlerKey}"; + } + + public static string BuildSystemFileAssociationShell(string extension) + { + return $@"SystemFileAssociations\{extension}\shell"; + } + + public static string BuildProgIdShell(string progId) + { + return $@"{progId}\shell"; + } + + public static bool IsDirectoryLikeAssociationType(string? type) + { + if (string.IsNullOrWhiteSpace(type)) + { + return false; + } + + return string.Equals(type, DirectoryAssociationType, StringComparison.OrdinalIgnoreCase) + || string.Equals(type, FolderAssociationType, StringComparison.OrdinalIgnoreCase) + || string.Equals(type, DirectoryBackgroundAssociationType, StringComparison.OrdinalIgnoreCase); + } + } + + public static class InterfaceType + { + public const string StaticVerb = "Static Verb"; + public const string Skipped = "Skipped"; + } + + public static class IconSource + { + public const string ManifestAppLogo = "ManifestAppLogo"; + } + + public static class IconLocation + { + public const char HintSeparator = '|'; + public const string MsAppxUriPrefix = "ms-appx://"; + public const string IndirectStringPrefix = "@"; + public const string MrtPreferredTargetSizeToken = "targetsize-48"; + public const string MrtPreferredScaleToken = "scale-200"; + public const char IconResourceIndexSeparator = ','; + public const int IndirectStringBufferSize = 1024; + } + + public static class IconFileExtension + { + public const string Png = ".png"; + public const string Jpg = ".jpg"; + public const string Bmp = ".bmp"; + public const string Ico = ".ico"; + } + + public static class LocationSummary + { + public const string ModernShellUwp = "Modern Shell (UWP)"; + public const string StaticVerbRegistryShellPrefix = "Registry (Shell) - "; + } + + public static class RegistryLocationToken + { + public const string Disabled = "[Disabled]"; + public const string DisabledSuffix = " [Disabled]"; + public const char StaticVerbDisabledKeyPrefixChar = '-'; + public const string StaticVerbDisabledKeyPrefix = "-"; + } + + public static class RegistryToken + { + public const string ExtensionPrefix = "."; + } + + public static class StaticVerb + { + public const string CommandSubKeyName = "command"; + public const string MuiVerbValueName = "MUIVerb"; + public const string IconValueName = "Icon"; + public const string IgnoredVerbAttributes = "Attributes"; + public const string IgnoredVerbAnyCode = "AnyCode"; + public const char UniqueKeySeparator = '|'; + } + + public static class Runtime + { + public const int MaxParallelProbeTasks = 8; + public const int IpcTimeoutLikeRoundtripThresholdMs = 1900; + public const int HookReconnectStabilizationDelayMs = 1000; + public const int ClipboardRetryAttempts = 5; + public const int ClipboardRetryDelayMs = 100; + public const uint ClipboardCantOpenHResult = 0x800401D0; + public const long RealShellBenchmarkUnsupportedMs = -1; + public const string SkipUnstableHandlersEnvVar = "CMP_SKIP_UNSTABLE_HANDLERS"; + } + + private static readonly string[] KnownUnstableHandlerTokens = + { + "PintoStartScreen", + "NvcplDesktopContext", + "NvAppDesktopContext", + "NVIDIA CPL Context Menu Extension" + }; + + public static bool IsSkipUnstableHandlersEnabled() + { + return string.Equals( + Environment.GetEnvironmentVariable(Runtime.SkipUnstableHandlersEnvVar), + "1", + StringComparison.OrdinalIgnoreCase); + } + + public static bool ContainsKnownUnstableHandlerToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + foreach (var token in KnownUnstableHandlerTokens) + { + if (value.Contains(token, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + 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 IsRegistryManagedExtensionType(string? type) + { + return !IsPackagedExtensionType(type); + } + + public static bool IsDisabledRegistryLocation(string? location) + { + return !string.IsNullOrWhiteSpace(location) + && location.Contains(RegistryLocationToken.Disabled, StringComparison.OrdinalIgnoreCase); + } + + public static string BuildDisabledRegistryLocationLabel(string locationLabel) + { + return locationLabel + RegistryLocationToken.DisabledSuffix; + } + + public static string BuildExtensionRegistryLocationLabel(string extension) + { + return $"{RegistryLocationLabel.Extension} ({extension})"; + } + + public static string BuildProgIdRegistryLocationLabel(string progId, string extension) + { + return $"{RegistryLocationLabel.ProgId} ({progId} for {extension})"; + } + + public static string BuildRegistryHandlerLocation(string locationLabel, string handlerName) + { + return $"{locationLabel} ({handlerName})"; + } + + public static string BuildStaticVerbRegistryLocation(string? registryPath) + { + string hive = ExtractRegistryHive(registryPath); + return LocationSummary.StaticVerbRegistryShellPrefix + hive; + } + + public static bool IsStaticVerbRegistryPathDisabled(string? registryPath) + { + if (string.IsNullOrWhiteSpace(registryPath)) + { + return false; + } + + int lastSeparatorIndex = registryPath.LastIndexOf('\\'); + string terminalSegment = lastSeparatorIndex >= 0 + ? registryPath[(lastSeparatorIndex + 1)..] + : registryPath; + + return terminalSegment.StartsWith(RegistryLocationToken.StaticVerbDisabledKeyPrefix, StringComparison.Ordinal); + } + + public static bool IsIgnoredStaticVerbName(string? verbName) + { + if (string.IsNullOrWhiteSpace(verbName)) + { + return false; + } + + return string.Equals(verbName, StaticVerb.IgnoredVerbAttributes, StringComparison.OrdinalIgnoreCase) + || string.Equals(verbName, StaticVerb.IgnoredVerbAnyCode, StringComparison.OrdinalIgnoreCase); + } + + public static string BuildStaticVerbUniqueKey(string displayName, string command) + { + return $"{displayName}{StaticVerb.UniqueKeySeparator}{command}"; + } + + public static bool TryParseStaticVerbUniqueKey(string key, out string name, out string command) + { + name = string.Empty; + command = string.Empty; + + if (string.IsNullOrWhiteSpace(key)) + { + return false; + } + + int separatorIndex = key.IndexOf(StaticVerb.UniqueKeySeparator); + if (separatorIndex <= 0 || separatorIndex >= key.Length - 1) + { + return false; + } + + name = key[..separatorIndex]; + command = key[(separatorIndex + 1)..]; + return true; + } + + public static bool LooksLikeBracedClsid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + string trimmed = value.Trim(); + return trimmed.Length > 2 + && trimmed[0] == '{' + && trimmed[^1] == '}'; + } + + public static bool IsCategoryMatch(string? selectedCategory, string? resultCategory) + { + if (string.IsNullOrWhiteSpace(selectedCategory) + || string.Equals(selectedCategory, FilterCategory.All, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return string.Equals(selectedCategory, resultCategory, StringComparison.OrdinalIgnoreCase); + } + + public static bool IsFallbackLikeStatus(BenchmarkStatus status) + { + return status == BenchmarkStatus.RegistryFallback + || status == BenchmarkStatus.LoadError + || status == BenchmarkStatus.OrphanedMissingDll + || status == BenchmarkStatus.IpcTimeout; + } + + public static bool IsFallbackLikeStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + { + return false; + } + + if (TryParseStatus(status, out BenchmarkStatus parsedStatus)) + { + return IsFallbackLikeStatus(parsedStatus); + } + + return status.Contains(StatusToken.Fallback, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Error, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Orphaned, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Missing, StringComparison.OrdinalIgnoreCase); + } + + public static bool IsWarningLikeStatus(BenchmarkStatus status) + { + return status == BenchmarkStatus.RegistryFallback + || status == BenchmarkStatus.HookLoadedNoMenu + || status == BenchmarkStatus.LoadError + || status == BenchmarkStatus.OrphanedMissingDll + || status == BenchmarkStatus.IpcTimeout; + } + + public static bool IsWarningLikeStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + { + return false; + } + + if (TryParseStatus(status, out BenchmarkStatus parsedStatus)) + { + return IsWarningLikeStatus(parsedStatus); + } + + return status.StartsWith(GetStatusDisplayText(BenchmarkStatus.LoadError), StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Exception, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Failed, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.NotRegistered, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Invalid, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.NotFound, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Fallback, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.NoMenu, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Orphaned, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Missing, StringComparison.OrdinalIgnoreCase); + } + + public static bool IsNotMeasuredLikeStatus(BenchmarkStatus status) + { + return status == BenchmarkStatus.StaticNotMeasured + || status == BenchmarkStatus.HookLoadedNoMenu; + } + + public static bool IsNotMeasuredLikeStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + { + return false; + } + + if (TryParseStatus(status, out BenchmarkStatus parsedStatus)) + { + return IsNotMeasuredLikeStatus(parsedStatus); + } + + return status.Contains(StatusToken.NotMeasured, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.Unsupported, StringComparison.OrdinalIgnoreCase) + || status.Contains(StatusToken.NoMenu, StringComparison.OrdinalIgnoreCase); + } + + public static string GetStatusDisplayText(BenchmarkStatus status) + { + return status switch + { + BenchmarkStatus.Unknown => "Unknown", + BenchmarkStatus.Ok => "OK", + BenchmarkStatus.VerifiedViaHook => "Verified via Hook", + BenchmarkStatus.HookLoadedNoMenu => "Hook Loaded (No Menu)", + BenchmarkStatus.OrphanedMissingDll => "Orphaned / Missing DLL", + BenchmarkStatus.IpcTimeout => "IPC Timeout", + BenchmarkStatus.LoadError => "Load Error", + BenchmarkStatus.RegistryFallback => "Registry Fallback", + BenchmarkStatus.StaticNotMeasured => "Static (Not Measured)", + BenchmarkStatus.SkippedKnownUnstable => "Skipped (Known Unstable)", + BenchmarkStatus.DisabledPendingRestart => "Disabled (Pending Restart)", + BenchmarkStatus.EnabledPendingRestart => "Enabled (Pending Restart)", + _ => "Unknown" + }; + } + + public static string GetStatusLocalizationKey(BenchmarkStatus status) + { + return status switch + { + BenchmarkStatus.Unknown => "Dashboard.Status.Unknown", + BenchmarkStatus.Ok => "Dashboard.Status.Ok", + BenchmarkStatus.VerifiedViaHook => "Dashboard.Status.VerifiedViaHook", + BenchmarkStatus.HookLoadedNoMenu => "Dashboard.Status.HookLoadedNoMenu", + BenchmarkStatus.OrphanedMissingDll => "Dashboard.Status.OrphanedMissingDll", + BenchmarkStatus.IpcTimeout => "Dashboard.Status.IpcTimeout", + BenchmarkStatus.LoadError => "Dashboard.Status.LoadError", + BenchmarkStatus.RegistryFallback => "Dashboard.Status.RegistryFallback", + BenchmarkStatus.StaticNotMeasured => "Dashboard.Status.StaticNotMeasured", + BenchmarkStatus.SkippedKnownUnstable => "Dashboard.Status.SkippedKnownUnstable", + BenchmarkStatus.DisabledPendingRestart => "Dashboard.Status.DisabledPendingRestart", + BenchmarkStatus.EnabledPendingRestart => "Dashboard.Status.EnabledPendingRestart", + _ => "Dashboard.Status.Unknown" + }; + } + + public static string GetLocalizedStatusText(BenchmarkStatus status) + { + string key = GetStatusLocalizationKey(status); + string localized = LocalizationService.Instance[key]; + if (string.Equals(localized, key, StringComparison.Ordinal)) + { + return GetStatusDisplayText(status); + } + + return localized; + } + + public static bool TryParseStatus(string? status, out BenchmarkStatus parsedStatus) + { + parsedStatus = BenchmarkStatus.Unknown; + if (string.IsNullOrWhiteSpace(status)) + { + return false; + } + + foreach (BenchmarkStatus candidate in Enum.GetValues(typeof(BenchmarkStatus))) + { + if (string.Equals(status, GetStatusDisplayText(candidate), StringComparison.OrdinalIgnoreCase) + || string.Equals(status, candidate.ToString(), StringComparison.OrdinalIgnoreCase)) + { + parsedStatus = candidate; + return true; + } + } + + return false; + } + + public static bool IsTimeoutLikeError(string? error) + { + if (string.IsNullOrWhiteSpace(error)) + { + return false; + } + + return error.Contains(StatusToken.Timeout, StringComparison.OrdinalIgnoreCase); + } + + public static string ResolveCategoryFromLocations(IEnumerable locations) + { + if (locations == null) + { + return Category.File; + } + + int resolvedPriority = CategoryPriority.Unknown; + + foreach (var location in locations) + { + if (string.IsNullOrWhiteSpace(location)) + { + continue; + } + + int locationPriority = ResolveLocationPriority(location); + if (locationPriority == CategoryPriority.Background) + { + return Category.Background; + } + + if (locationPriority > resolvedPriority) + { + resolvedPriority = locationPriority; + } + } + + return resolvedPriority switch + { + CategoryPriority.Drive => Category.Drive, + CategoryPriority.Folder => Category.Folder, + CategoryPriority.File => Category.File, + _ => Category.File + }; + } + + private static int ResolveLocationPriority(string location) + { + if (location.Contains(CategoryLocationHint.Background, StringComparison.OrdinalIgnoreCase)) + { + return CategoryPriority.Background; + } + + if (location.Contains(CategoryLocationHint.Drive, StringComparison.OrdinalIgnoreCase)) + { + return CategoryPriority.Drive; + } + + if (ContainsAnyLocationHint(location, FolderLocationHints)) + { + return CategoryPriority.Folder; + } + + if (ContainsAnyLocationHint(location, FileLocationHints)) + { + return CategoryPriority.File; + } + + return CategoryPriority.Unknown; + } + + private static bool ContainsAnyLocationHint(string location, IEnumerable hints) + { + foreach (var hint in hints) + { + if (location.Contains(hint, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string ExtractRegistryHive(string? registryPath) + { + if (string.IsNullOrWhiteSpace(registryPath)) + { + return string.Empty; + } + + int firstSeparatorIndex = registryPath.IndexOf('\\'); + if (firstSeparatorIndex <= 0) + { + return registryPath; + } + + return registryPath[..firstSeparatorIndex]; + } + } +} diff --git a/ContextMenuProfiler.UI/Core/BenchmarkService.cs b/ContextMenuProfiler.UI/Core/BenchmarkService.cs index ff792ef..a778be4 100644 --- a/ContextMenuProfiler.UI/Core/BenchmarkService.cs +++ b/ContextMenuProfiler.UI/Core/BenchmarkService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -17,8 +18,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 BenchmarkStatus 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 +44,8 @@ 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; + public List? ObservedMenuDisplayNames { get; set; } } internal class ClsidMetadata @@ -57,235 +59,573 @@ internal class ClsidMetadata public class BenchmarkService { private static readonly bool SkipKnownUnstableHandlers = - string.Equals(Environment.GetEnvironmentVariable("CMP_SKIP_UNSTABLE_HANDLERS"), "1", StringComparison.OrdinalIgnoreCase); - - private static readonly string[] KnownUnstableHandlerTokens = - { - "PintoStartScreen", - "NvcplDesktopContext", - "NvAppDesktopContext", - "NVIDIA CPL Context Menu Extension" - }; + BenchmarkSemantics.IsSkipUnstableHandlersEnabled(); 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 List RunSystemBenchmark(ScanMode mode, string? scanId) + { + return Task.Run(() => RunSystemBenchmarkAsync(mode, null, scanId)).GetAwaiter().GetResult(); + } + public async Task> RunSystemBenchmarkAsync(ScanMode mode = ScanMode.Targeted, IProgress? progress = null) { + return await RunSystemBenchmarkAsync(mode, progress, null); + } + + public async Task> RunSystemBenchmarkAsync(ScanMode mode, IProgress? progress, string? scanId) + { + using var fileContext = ShellTestContext.Create(false); + var scanSw = Stopwatch.StartNew(); + + var registryHandlers = RegistryScanner.ScanHandlers(mode); + var staticVerbs = RegistryScanner.ScanStaticVerbs(); + + LogService.Instance.InfoEvent( + "scan.registry_scan_completed", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["scan_scope"] = "system", + ["scan_depth"] = ResolveScanDepth(mode), + ["scan_mode"] = ResolveScanDepth(mode), + ["scope"] = "system", + ["com_handler_count"] = registryHandlers.Count, + ["static_verb_group_count"] = staticVerbs.Count + }); + + var results = await RunBenchmarkCoreAsync(registryHandlers, staticVerbs, null, fileContext.Path, progress, scanId); + scanSw.Stop(); + + LogService.Instance.InfoEvent( + "scan.service_completed", + fields: BuildSummaryFields(results, scanSw.ElapsedMilliseconds, scanId, "system", ResolveScanDepth(mode))); + + return results; + } + + public List RunBenchmark(string targetPath, string? scanId) + { + return Task.Run(() => RunBenchmarkAsync(targetPath, null, scanId)).GetAwaiter().GetResult(); + } + + public List RunBenchmark(string targetPath) + { + return Task.Run(() => RunBenchmarkAsync(targetPath)).GetAwaiter().GetResult(); + } + + public async Task> RunBenchmarkAsync(string targetPath, IProgress? progress = null) + { + return await RunBenchmarkAsync(targetPath, progress, null); + } + + public async Task> RunBenchmarkAsync(string targetPath, IProgress? progress, string? scanId) + { + if (string.IsNullOrWhiteSpace(targetPath)) + { + LogService.Instance.Warning("RunBenchmarkAsync received an empty target path; returning no results."); + return new List(); + } + + var scanSw = Stopwatch.StartNew(); + + var registryHandlers = RegistryScanner.ScanHandlersForPath(targetPath); + var staticVerbs = RegistryScanner.ScanStaticVerbsForPath(targetPath); + + LogService.Instance.InfoEvent( + "scan.registry_scan_completed", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["scan_scope"] = "file", + ["scan_depth"] = "targeted", + ["scan_mode"] = "targeted", + ["scope"] = "file", + ["target_path"] = targetPath, + ["com_handler_count"] = registryHandlers.Count, + ["static_verb_group_count"] = staticVerbs.Count + }); + + var results = await RunBenchmarkCoreAsync(registryHandlers, staticVerbs, targetPath, targetPath, progress, scanId); + scanSw.Stop(); + + LogService.Instance.InfoEvent( + "scan.service_completed", + fields: BuildSummaryFields(results, scanSw.ElapsedMilliseconds, scanId, "file", "targeted", targetPath)); + + return results; + } + + private async Task> RunBenchmarkCoreAsync( + Dictionary> registryHandlers, + Dictionary> staticVerbs, + string? packageTargetPath, + string hookContextPath, + IProgress? progress, + string? scanId) + { + var coreSw = Stopwatch.StartNew(); var allResults = new ConcurrentBag(); var resultsMap = new ConcurrentDictionary(); - var semaphore = new SemaphoreSlim(8); - - using (var fileContext = ShellTestContext.Create(false)) - { - // 1. Scan All Registry Handlers (COM) - var registryHandlers = RegistryScanner.ScanHandlers(mode); - var comTasks = registryHandlers.Select(async clsidEntry => + var semaphore = new SemaphoreSlim(BenchmarkSemantics.Runtime.MaxParallelProbeTasks); + + LogService.Instance.InfoEvent( + "scan.benchmark_core_started", + fields: new Dictionary { - 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 - { - 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(); } + ["scan_id"] = scanId, + ["com_handler_count"] = registryHandlers.Count, + ["static_verb_group_count"] = staticVerbs.Count, + ["has_package_target"] = !string.IsNullOrWhiteSpace(packageTargetPath) }); - await Task.WhenAll(comTasks); + var phaseSw = Stopwatch.StartNew(); + await ProcessComHandlersAsync(registryHandlers, hookContextPath, allResults, resultsMap, semaphore, progress, scanId); + phaseSw.Stop(); + LogService.Instance.InfoEvent( + "scan.phase_completed", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["phase"] = "com_handlers", + ["duration_ms"] = phaseSw.ElapsedMilliseconds, + ["result_count"] = allResults.Count + }); - // 2. Scan Static Verbs - var staticVerbs = RegistryScanner.ScanStaticVerbs(); - foreach (var verbEntry in staticVerbs) + phaseSw.Restart(); + ProcessStaticVerbEntries(staticVerbs, allResults, progress, scanId); + phaseSw.Stop(); + LogService.Instance.InfoEvent( + "scan.phase_completed", + fields: new Dictionary { - string key = verbEntry.Key; - var paths = verbEntry.Value; - string name = key.Split('|')[0]; - string command = key.Split('|')[1]; + ["scan_id"] = scanId, + ["phase"] = "static_verbs", + ["duration_ms"] = phaseSw.ElapsedMilliseconds, + ["result_count"] = allResults.Count + }); + + phaseSw.Restart(); + await ProcessPackagedExtensionsAsync(packageTargetPath, hookContextPath, allResults, resultsMap, semaphore, progress, scanId); + phaseSw.Stop(); + LogService.Instance.InfoEvent( + "scan.phase_completed", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["phase"] = "packaged_extensions", + ["duration_ms"] = phaseSw.ElapsedMilliseconds, + ["result_count"] = allResults.Count + }); - var verbResult = new BenchmarkResult + coreSw.Stop(); + LogService.Instance.InfoEvent( + "scan.benchmark_core_completed", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["duration_ms"] = coreSw.ElapsedMilliseconds, + ["result_count"] = allResults.Count + }); + + return allResults.ToList(); + } + + private async Task ProcessComHandlersAsync( + Dictionary> registryHandlers, + string hookContextPath, + ConcurrentBag allResults, + ConcurrentDictionary resultsMap, + SemaphoreSlim semaphore, + IProgress? progress, + string? scanId) + { + var comTasks = registryHandlers.Select(clsidEntry => + RunWithSemaphoreAsync(semaphore, async () => + { + var clsid = clsidEntry.Key; + var handlerInfos = clsidEntry.Value; + var meta = QueryClsidMetadata(clsid); + var result = 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" + Clsid = clsid, + Type = BenchmarkSemantics.Type.Com, + RegistryEntries = handlerInfos.ToList(), + Name = meta.Name, + BinaryPath = meta.BinaryPath, + ThreadingModel = meta.ThreadingModel, + FriendlyName = meta.FriendlyName }; - 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); + if (string.IsNullOrEmpty(result.Name)) + { + result.Name = string.Format( + LocalizationService.Instance["Dashboard.Value.UnknownWithClsid"], + clsid); + } - allResults.Add(verbResult); - progress?.Report(verbResult); + if (!resultsMap.TryAdd(clsid, result)) + { + return; + } + + result.Category = DetermineCategory(result.RegistryEntries.Select(e => e.Location)); + await ProcessMeasuredResultAsync(result, hookContextPath, allResults, progress, scanId); + })); + + await Task.WhenAll(comTasks); + } + + private void ProcessStaticVerbEntries( + Dictionary> staticVerbs, + ConcurrentBag allResults, + IProgress? progress, + string? scanId) + { + foreach (var verbEntry in staticVerbs) + { + var verbResult = CreateStaticVerbResult(verbEntry.Key, verbEntry.Value); + if (verbResult == null) + { + continue; } - // 3. Scan UWP Extensions (Parallelized) - var uwpTasks = PackageScanner.ScanPackagedExtensions(null) - .Where(r => r.Clsid.HasValue && !resultsMap.ContainsKey(r.Clsid.Value)) - .Select(async uwpResult => + AddAndReportResult(allResults, verbResult, progress); + LogService.Instance.InfoEvent( + "scan.item_processed", + fields: BuildItemFields(verbResult, scanId, "static")); + } + } + + private async Task ProcessPackagedExtensionsAsync( + string? packageTargetPath, + string hookContextPath, + ConcurrentBag allResults, + ConcurrentDictionary resultsMap, + SemaphoreSlim semaphore, + IProgress? progress, + string? scanId) + { + var uwpTasks = PackageScanner.ScanPackagedExtensions(packageTargetPath) + .Where(r => r.Clsid.HasValue) + .Select(uwpResult => + RunWithSemaphoreAsync(semaphore, async () => { - await semaphore.WaitAsync(); - try + if (!resultsMap.TryAdd(uwpResult.Clsid!.Value, uwpResult)) { - 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); + return; } - finally { semaphore.Release(); } - }); - await Task.WhenAll(uwpTasks); + uwpResult.Category = BenchmarkSemantics.Category.Uwp; + await ProcessMeasuredResultAsync(uwpResult, hookContextPath, allResults, progress, scanId); + })); + + await Task.WhenAll(uwpTasks); + } + + private static async Task RunWithSemaphoreAsync(SemaphoreSlim semaphore, Func action) + { + await semaphore.WaitAsync(); + try + { + await action(); + } + finally + { + semaphore.Release(); + } + } + + private static void AddAndReportResult( + ConcurrentBag allResults, + BenchmarkResult result, + IProgress? progress) + { + allResults.Add(result); + progress?.Report(result); + } + + private async Task ProcessMeasuredResultAsync( + BenchmarkResult result, + string hookContextPath, + ConcurrentBag allResults, + IProgress? progress, + string? scanId) + { + await EnrichBenchmarkResultAsync(result, hookContextPath, scanId); + result.IsEnabled = ResolveEnabledState(result); + result.LocationSummary = ResolveLocationSummary(result); + AddAndReportResult(allResults, result, progress); + + LogService.Instance.InfoEvent( + "scan.item_processed", + fields: BuildItemFields(result, scanId, "measured")); + } + + private static bool ResolveEnabledState(BenchmarkResult result) + { + bool isBlocked = result.Clsid.HasValue && ExtensionManager.IsExtensionBlocked(result.Clsid.Value); + if (isBlocked) + { + return false; + } + + if (BenchmarkSemantics.IsRegistryManagedExtensionType(result.Type)) + { + return !result.RegistryEntries.Any(e => BenchmarkSemantics.IsDisabledRegistryLocation(e.Location)); + } + + return true; + } + + private static string ResolveLocationSummary(BenchmarkResult result) + { + if (BenchmarkSemantics.IsPackagedExtensionType(result.Type)) + { + return BenchmarkSemantics.LocationSummary.ModernShellUwp; + } + + return string.Join(", ", result.RegistryEntries.Select(e => e.Location).Distinct()); + } - return allResults.ToList(); + private BenchmarkResult? CreateStaticVerbResult(string key, List paths) + { + if (!BenchmarkSemantics.TryParseStaticVerbUniqueKey(key, out string name, out string command)) + { + LogService.Instance.Warning($"Skip malformed static verb entry key: '{key}'"); + return null; } + + var result = new BenchmarkResult + { + Name = name, + Type = BenchmarkSemantics.Type.Static, + Status = BenchmarkSemantics.Status.StaticNotMeasured, + BinaryPath = ExtractExecutablePath(command), + RegistryEntries = paths.Select(p => new RegistryHandlerInfo + { + Path = p, + Location = BenchmarkSemantics.BuildStaticVerbRegistryLocation(p) + }).ToList(), + InterfaceType = BenchmarkSemantics.InterfaceType.StaticVerb, + DetailedStatus = LocalizationService.Instance["Dashboard.Detail.StaticNotMeasured"], + TotalTime = 0, + Category = BenchmarkSemantics.Category.Static + }; + + result.IsEnabled = !paths.Any(BenchmarkSemantics.IsStaticVerbRegistryPathDisabled); + result.LocationSummary = string.Join(", ", result.RegistryEntries.Select(e => e.Location).Distinct()); + result.IconLocation = ResolveStaticVerbIcon(paths.First(), result.BinaryPath); + return result; } - private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string contextPath) + private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string contextPath, string? scanId) { if (!result.Clsid.HasValue) return; if (SkipKnownUnstableHandlers && IsKnownUnstableHandler(result)) { - result.Status = "Skipped (Known Unstable)"; - result.DetailedStatus = "Skipped Hook invocation for a known unstable system handler to avoid scan-wide IPC stalls."; - result.InterfaceType = "Skipped"; - result.CreateTime = 0; - result.InitTime = 0; - result.QueryTime = 0; - result.TotalTime = 0; + MarkAsSkippedKnownUnstable(result); return; } // Check for Orphaned / Missing DLL if (!string.IsNullOrEmpty(result.BinaryPath) && !File.Exists(result.BinaryPath)) { - result.Status = "Orphaned / Missing DLL"; - result.DetailedStatus = $"The file '{result.BinaryPath}' was not found on disk. This extension is likely corrupted or uninstalled."; + result.Status = BenchmarkSemantics.Status.OrphanedMissingDll; + result.DetailedStatus = string.Format( + LocalizationService.Instance["Dashboard.Detail.OrphanedMissingDll"], + result.BinaryPath); } - var hookCall = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), contextPath, result.BinaryPath); + var hookCall = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), contextPath, result.BinaryPath, scanId); var hookData = hookCall.data; result.WallClockTime = hookCall.total_ms; result.LockWaitTime = hookCall.lock_wait_ms; result.ConnectTime = hookCall.connect_ms; result.IpcRoundTripTime = hookCall.roundtrip_ms; - if (hookData != null && hookData.success) + if (hookData?.success == true) { - result.InterfaceType = hookData.@interface; - 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)) - { - result.Name = hookData.names.Replace("|", ", "); - } - if (result.Status == "Unknown") result.Status = "Verified via Hook"; - } - else if (result.Status == "Unknown" || result.Status == "OK") - { - result.Status = "Hook Loaded (No Menu)"; - result.DetailedStatus = "The extension was loaded by the Hook service but it did not provide any context menu items for the test context."; - } - - string? winnerIcon = null; - if (!string.IsNullOrEmpty(hookData.reg_icon) && (hookData.reg_icon.Contains(",") || hookData.reg_icon.ToLower().EndsWith(".ico"))) - winnerIcon = hookData.reg_icon; - if (winnerIcon == null && !string.IsNullOrEmpty(hookData.icons)) - winnerIcon = hookData.icons.Split('|').FirstOrDefault(i => !string.IsNullOrEmpty(i) && i != "NONE"); - - if (winnerIcon != null) result.IconLocation = winnerIcon; - - result.CreateTime = (long)hookData.create_ms; - result.InitTime = (long)hookData.init_ms; - result.QueryTime = (long)hookData.query_ms; - result.TotalTime = result.CreateTime + result.InitTime + result.QueryTime; - } - else if (hookData != null && !hookData.success) - { - if (!string.IsNullOrEmpty(hookData.error) && hookData.error.Contains("Timeout", StringComparison.OrdinalIgnoreCase)) + ApplyHookSuccessResult(result, hookData); + } + else if (hookData != null) + { + ApplyHookErrorResult(result, hookData); + } + else + { + ApplyHookUnavailableFallback(result, hookCall.roundtrip_ms, hookCall.ipc_error); + } + } + + private static void MarkAsSkippedKnownUnstable(BenchmarkResult result) + { + result.Status = BenchmarkSemantics.Status.SkippedKnownUnstable; + result.DetailedStatus = LocalizationService.Instance["Dashboard.Detail.SkippedKnownUnstable"]; + result.InterfaceType = BenchmarkSemantics.InterfaceType.Skipped; + result.CreateTime = 0; + result.InitTime = 0; + result.QueryTime = 0; + result.TotalTime = 0; + } + + private static void ApplyHookSuccessResult(BenchmarkResult result, HookResponse hookData) + { + result.InterfaceType = hookData.@interface; + if (!string.IsNullOrEmpty(hookData.names)) + { + result.ObservedMenuDisplayNames = ParseHookMenuDisplayNames(hookData.names); + + if (BenchmarkSemantics.IsRegistryManagedExtensionType(result.Type)) { - result.Status = "IPC Timeout"; - result.DetailedStatus = $"Hook service timed out while probing this extension. Error: {hookData.error}"; + result.Name = string.Join( + ", ", + result.ObservedMenuDisplayNames); } - else + + if (result.Status == BenchmarkSemantics.Status.Unknown) { - result.Status = "Load Error"; - result.DetailedStatus = $"The Hook service failed to load this extension. Error: {hookData.error ?? "Unknown Error"}"; + result.Status = BenchmarkSemantics.Status.VerifiedViaHook; } } - else if (hookData == null) + else if (result.Status == BenchmarkSemantics.Status.Unknown || result.Status == BenchmarkSemantics.Status.Ok) { - if (result.Status != "Load Error" && result.Status != "Orphaned / Missing DLL") - { - if (hookCall.roundtrip_ms >= 1900) - { - result.Status = "IPC Timeout"; - result.DetailedStatus = "Hook service response timed out for this extension. Data is based on registry scan only."; - } - else - { - result.Status = "Registry Fallback"; - result.DetailedStatus = "The Hook service could not be reached or failed to process this extension. Data is based on registry scan only."; - } - } + result.Status = BenchmarkSemantics.Status.HookLoadedNoMenu; + result.DetailedStatus = LocalizationService.Instance["Dashboard.Detail.HookLoadedNoMenu"]; } + + string? winnerIcon = ResolveHookIconLocation(hookData); + if (winnerIcon != null) + { + result.IconLocation = winnerIcon; + } + + result.CreateTime = (long)hookData.create_ms; + result.InitTime = (long)hookData.init_ms; + result.QueryTime = (long)hookData.query_ms; + result.TotalTime = result.CreateTime + result.InitTime + result.QueryTime; + } + + private static List ParseHookMenuDisplayNames(string names) + { + return names + .Split(HookIpcSemantics.Response.MultiValueDelimiter) + .Select(n => n.Trim()) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + private static string? ResolveHookIconLocation(HookResponse hookData) + { + if (!string.IsNullOrEmpty(hookData.reg_icon) + && (hookData.reg_icon.Contains(BenchmarkSemantics.IconLocation.IconResourceIndexSeparator) + || hookData.reg_icon.EndsWith(BenchmarkSemantics.IconFileExtension.Ico, StringComparison.OrdinalIgnoreCase))) + { + return hookData.reg_icon; + } + + if (string.IsNullOrEmpty(hookData.icons)) + { + return null; + } + + return hookData.icons + .Split(HookIpcSemantics.Response.MultiValueDelimiter) + .FirstOrDefault(i => + !string.IsNullOrEmpty(i) + && !string.Equals(i, HookIpcSemantics.Response.NoIconToken, StringComparison.OrdinalIgnoreCase)); + } + + private static void ApplyHookErrorResult(BenchmarkResult result, HookResponse hookData) + { + string hookError = BuildHookErrorDetails(hookData.error, hookData.code); + + if (IsTimeoutLikeHookFailure(hookData)) + { + result.Status = BenchmarkSemantics.Status.IpcTimeout; + result.DetailedStatus = string.Format( + LocalizationService.Instance["Dashboard.Detail.HookProbeTimeoutWithError"], + hookError); + return; + } + + result.Status = BenchmarkSemantics.Status.LoadError; + result.DetailedStatus = string.Format( + LocalizationService.Instance["Dashboard.Detail.HookLoadErrorWithError"], + hookError); + } + + private static void ApplyHookUnavailableFallback(BenchmarkResult result, long roundTripMs, string? ipcError) + { + if (result.Status == BenchmarkSemantics.Status.LoadError || result.Status == BenchmarkSemantics.Status.OrphanedMissingDll) + { + return; + } + + if (roundTripMs >= BenchmarkSemantics.Runtime.IpcTimeoutLikeRoundtripThresholdMs) + { + result.Status = BenchmarkSemantics.Status.IpcTimeout; + result.DetailedStatus = AttachIpcReason( + LocalizationService.Instance["Dashboard.Detail.HookResponseTimeoutFallback"], + ipcError); + return; + } + + result.Status = BenchmarkSemantics.Status.RegistryFallback; + result.DetailedStatus = AttachIpcReason( + LocalizationService.Instance["Dashboard.Detail.HookUnavailableFallback"], + ipcError); + } + + private static bool IsTimeoutLikeHookFailure(HookResponse hookData) + { + if (string.Equals(hookData.code, "E_TIMEOUT", StringComparison.OrdinalIgnoreCase) + || string.Equals(hookData.code, "E_REQ_HEADER", StringComparison.OrdinalIgnoreCase) + || string.Equals(hookData.code, "E_REQ_READ", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return BenchmarkSemantics.IsTimeoutLikeError(hookData.error); + } + + private static string BuildHookErrorDetails(string? error, string? code) + { + string resolvedError = string.IsNullOrWhiteSpace(error) + ? LocalizationService.Instance["Dashboard.Value.Unknown"] + : error; + + if (string.IsNullOrWhiteSpace(code)) + { + return resolvedError; + } + + return $"{code}: {resolvedError}"; + } + + private static string AttachIpcReason(string detail, string? ipcError) + { + if (string.IsNullOrWhiteSpace(ipcError)) + { + return detail; + } + + return $"{detail} ({ipcError})"; } private static bool IsKnownUnstableHandler(BenchmarkResult result) { - if (!string.IsNullOrWhiteSpace(result.Name) && - KnownUnstableHandlerTokens.Any(token => result.Name.Contains(token, StringComparison.OrdinalIgnoreCase))) + if (BenchmarkSemantics.ContainsKnownUnstableHandlerToken(result.Name)) { return true; } - if (!string.IsNullOrWhiteSpace(result.FriendlyName) && - KnownUnstableHandlerTokens.Any(token => result.FriendlyName.Contains(token, StringComparison.OrdinalIgnoreCase))) + if (BenchmarkSemantics.ContainsKnownUnstableHandlerToken(result.FriendlyName)) { return true; } @@ -294,10 +634,8 @@ private static bool IsKnownUnstableHandler(BenchmarkResult result) { foreach (var entry in result.RegistryEntries) { - if ((!string.IsNullOrWhiteSpace(entry.Location) && - KnownUnstableHandlerTokens.Any(token => entry.Location.Contains(token, StringComparison.OrdinalIgnoreCase))) || - (!string.IsNullOrWhiteSpace(entry.Path) && - KnownUnstableHandlerTokens.Any(token => entry.Path.Contains(token, StringComparison.OrdinalIgnoreCase)))) + if (BenchmarkSemantics.ContainsKnownUnstableHandlerToken(entry.Location) + || BenchmarkSemantics.ContainsKnownUnstableHandlerToken(entry.Path)) { return true; } @@ -309,92 +647,129 @@ private static bool IsKnownUnstableHandler(BenchmarkResult result) private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) { - var meta = new ClsidMetadata(); + if (depth >= 3) + { + return new ClsidMetadata(); + } + string clsidB = clsid.ToString("B"); + var meta = TryQueryRegisteredClsidMetadata(clsid, clsidB, depth) + ?? TryQueryPackagedClsidMetadata(clsid, clsidB) + ?? new ClsidMetadata(); - // Prevent infinite recursion or too deep nesting - if (depth >= 3) return meta; + return NormalizeClsidMetadata(meta); + } - using (var key = ShellUtils.OpenClsidKey(clsidB)) + private ClsidMetadata? TryQueryRegisteredClsidMetadata(Guid clsid, string clsidB, int depth) + { + using var key = ShellUtils.OpenClsidKey(clsidB); + if (key == null) { - if (key != null) - { - meta.Name = key.GetValue("") as string ?? ""; - meta.FriendlyName = key.GetValue("FriendlyName") as string ?? ""; + return null; + } - // Try InprocServer32 - using (var serverKey = key.OpenSubKey("InprocServer32")) - { - if (serverKey != null) - { - meta.BinaryPath = serverKey.GetValue("") as string ?? ""; - meta.ThreadingModel = serverKey.GetValue("ThreadingModel") as string ?? ""; - } - } + var meta = new ClsidMetadata(); + meta.Name = key.GetValue("") as string ?? ""; + meta.FriendlyName = key.GetValue(ComRegistrySemantics.FriendlyNameValueName) as string ?? ""; + PopulateFromInprocServerKey(key, meta); - // If no path, check TreatAs (Alias) - if (string.IsNullOrEmpty(meta.BinaryPath)) - { - string? treatAs = key.OpenSubKey("TreatAs")?.GetValue("") as string; - if (!string.IsNullOrEmpty(treatAs) && Guid.TryParse(treatAs, out Guid otherGuid) && otherGuid != clsid) - { - var otherMeta = QueryClsidMetadata(otherGuid, depth + 1); - if (string.IsNullOrEmpty(meta.Name)) meta.Name = otherMeta.Name; - meta.BinaryPath = otherMeta.BinaryPath; - meta.ThreadingModel = otherMeta.ThreadingModel; - } - } + if (string.IsNullOrEmpty(meta.BinaryPath)) + { + PopulateFromTreatAsAlias(clsid, key, meta, depth); + } - // If still no path, check AppID (Surrogates) - if (string.IsNullOrEmpty(meta.BinaryPath)) - { - string? appId = key.GetValue("AppID") as string; - if (!string.IsNullOrEmpty(appId)) - { - using (var appKey = Registry.ClassesRoot.OpenSubKey($@"AppID\{appId}")) - { - string? dllSurrogate = appKey?.GetValue("DllSurrogate") as string; - meta.BinaryPath = dllSurrogate != null && string.IsNullOrEmpty(dllSurrogate) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "dllhost.exe") - : (dllSurrogate ?? ""); - } - } - } - } - else - { - // Check Packaged COM - using (var pkgKey = Registry.ClassesRoot.OpenSubKey($@"PackagedCom\ClassIndex\{clsidB}")) - { - string? packageFullName = pkgKey?.GetValue("") as string; - if (!string.IsNullOrEmpty(packageFullName)) - { - meta.BinaryPath = ResolvePackageDllPath(packageFullName, clsid) ?? ""; - meta.Name = QueryPackagedDisplayName(clsidB) ?? ""; - } - } - } + if (string.IsNullOrEmpty(meta.BinaryPath)) + { + PopulateFromAppIdSurrogate(key, meta); } + return meta; + } + + private ClsidMetadata? TryQueryPackagedClsidMetadata(Guid clsid, string clsidB) + { + using var pkgKey = Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.BuildPackagedComClassIndexPath(clsidB)); + string? packageFullName = pkgKey?.GetValue("") as string; + if (string.IsNullOrEmpty(packageFullName)) + { + return null; + } + + return new ClsidMetadata + { + BinaryPath = ResolvePackageDllPath(packageFullName, clsid) ?? "", + Name = QueryPackagedDisplayName(clsidB) ?? "" + }; + } + + private static ClsidMetadata NormalizeClsidMetadata(ClsidMetadata meta) + { meta.Name = ShellUtils.ResolveMuiString(meta.Name); if (string.IsNullOrEmpty(meta.Name) && !string.IsNullOrEmpty(meta.BinaryPath)) + { meta.Name = Path.GetFileName(meta.BinaryPath); + } return meta; } + private static void PopulateFromInprocServerKey(RegistryKey clsidKey, ClsidMetadata meta) + { + using var serverKey = clsidKey.OpenSubKey(ComRegistrySemantics.InprocServer32SubKeyName); + if (serverKey == null) + { + return; + } + + meta.BinaryPath = serverKey.GetValue("") as string ?? ""; + meta.ThreadingModel = serverKey.GetValue(ComRegistrySemantics.ThreadingModelValueName) as string ?? ""; + } + + private void PopulateFromTreatAsAlias(Guid originalClsid, RegistryKey clsidKey, ClsidMetadata meta, int depth) + { + string? treatAs = clsidKey.OpenSubKey(ComRegistrySemantics.TreatAsSubKeyName)?.GetValue("") as string; + if (string.IsNullOrEmpty(treatAs) || !Guid.TryParse(treatAs, out Guid otherGuid) || otherGuid == originalClsid) + { + return; + } + + var otherMeta = QueryClsidMetadata(otherGuid, depth + 1); + if (string.IsNullOrEmpty(meta.Name)) + { + meta.Name = otherMeta.Name; + } + + meta.BinaryPath = otherMeta.BinaryPath; + meta.ThreadingModel = otherMeta.ThreadingModel; + } + + private static void PopulateFromAppIdSurrogate(RegistryKey clsidKey, ClsidMetadata meta) + { + string? appId = clsidKey.GetValue(ComRegistrySemantics.AppIdValueName) as string; + if (string.IsNullOrEmpty(appId)) + { + return; + } + + using var appKey = Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.BuildAppIdPath(appId)); + string? dllSurrogate = appKey?.GetValue(ComRegistrySemantics.DllSurrogateValueName) as string; + meta.BinaryPath = dllSurrogate != null && string.IsNullOrEmpty(dllSurrogate) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), ComRegistrySemantics.DllHostExecutableName) + : (dllSurrogate ?? ""); + } + private string? QueryPackagedDisplayName(string clsidB) { try { - using (var pkgKey = Registry.ClassesRoot.OpenSubKey($@"PackagedCom\Package")) + using (var pkgKey = Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.PackagedComPackagePrefix)) { if (pkgKey == null) return null; foreach (var pkgName in pkgKey.GetSubKeyNames()) { - using (var clsKey = pkgKey.OpenSubKey($@"{pkgName}\Class\{clsidB}")) + using (var clsKey = pkgKey.OpenSubKey(ComRegistrySemantics.BuildPackagedComPackageClassPath(pkgName, clsidB))) { - string? name = clsKey?.GetValue("DisplayName") as string; + string? name = clsKey?.GetValue(ComRegistrySemantics.DisplayNameValueName) as string; if (!string.IsNullOrEmpty(name)) return name; } } @@ -432,7 +807,7 @@ private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) { using (var key = Registry.ClassesRoot.OpenSubKey(regPath)) { - string? icon = key?.GetValue("Icon") as string; + string? icon = key?.GetValue(BenchmarkSemantics.StaticVerb.IconValueName) as string; if (!string.IsNullOrEmpty(icon)) return icon; } } @@ -448,15 +823,15 @@ private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) try { // Look up package installation path - using (var key = Registry.ClassesRoot.OpenSubKey($@"Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\PackageRepository\Packages\{packageFullName}")) + using (var key = Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.BuildPackageRepositoryPath(packageFullName))) { - string? installPath = key?.GetValue("Path") as string; + string? installPath = key?.GetValue(ComRegistrySemantics.PackageInstallPathValueName) as string; if (string.IsNullOrEmpty(installPath)) return null; // Now find the relative DLL path from PackagedCom\Package // We need the short name (Package Family Name or part of full name) - string packageId = packageFullName.Split('_')[0]; - using (var pkgKey = Registry.ClassesRoot.OpenSubKey($@"PackagedCom\Package")) + string packageId = ComRegistrySemantics.ExtractPackageIdPrefix(packageFullName); + using (var pkgKey = Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.PackagedComPackagePrefix)) { if (pkgKey != null) { @@ -464,9 +839,9 @@ private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) { if (name.StartsWith(packageId, StringComparison.OrdinalIgnoreCase)) { - using (var clsKey = pkgKey.OpenSubKey($@"{name}\Class\{clsid:B}")) + using (var clsKey = pkgKey.OpenSubKey(ComRegistrySemantics.BuildPackagedComPackageClassPath(name, clsid.ToString("B")))) { - string? relDllPath = clsKey?.GetValue("DllPath") as string; + string? relDllPath = clsKey?.GetValue(ComRegistrySemantics.DllPathValueName) as string; if (!string.IsNullOrEmpty(relDllPath)) { return Path.Combine(installPath, relDllPath); @@ -486,22 +861,70 @@ private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) } } - public List RunBenchmark(string targetPath) + public long RunRealShellBenchmark(string? filePath = null) + => BenchmarkSemantics.Runtime.RealShellBenchmarkUnsupportedMs; + + private string DetermineCategory(IEnumerable locations) { - return RunSystemBenchmark(ScanMode.Targeted); // Simplified for now + return BenchmarkSemantics.ResolveCategoryFromLocations(locations); } - public long RunRealShellBenchmark(string? filePath = null) => -1; + private static Dictionary BuildSummaryFields( + List results, + long durationMs, + string? scanId, + string scanScope, + string scanDepth, + string? targetPath = null) + { + int measuredCount = results.Count(r => r.TotalTime > 0); + int fallbackCount = results.Count(r => BenchmarkSemantics.IsFallbackLikeStatus(r.Status)); - private string DetermineCategory(IEnumerable locations) + return new Dictionary + { + ["scan_id"] = scanId, + ["scan_scope"] = scanScope, + ["scan_depth"] = scanDepth, + ["scan_mode"] = scanDepth, + ["scope"] = scanScope, + ["target_path"] = targetPath, + ["duration_ms"] = durationMs, + ["result_count"] = results.Count, + ["measured_count"] = measuredCount, + ["fallback_count"] = fallbackCount + }; + } + + private static string ResolveScanDepth(ScanMode mode) { - 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"; - - return "File"; // Default + return mode == ScanMode.Full ? "full" : "targeted"; + } + + private static Dictionary BuildItemFields(BenchmarkResult result, string? scanId, string source) + { + return new Dictionary + { + ["scan_id"] = scanId, + ["source"] = source, + ["clsid"] = result.Clsid?.ToString("B"), + ["name"] = result.Name, + ["friendly_name"] = string.IsNullOrWhiteSpace(result.FriendlyName) ? null : result.FriendlyName, + ["observed_menu_display_names"] = result.ObservedMenuDisplayNames, + ["observed_menu_display_name_count"] = result.ObservedMenuDisplayNames?.Count ?? 0, + ["type"] = result.Type, + ["status"] = result.Status, + ["total_ms"] = result.TotalTime, + ["create_ms"] = result.CreateTime, + ["init_ms"] = result.InitTime, + ["query_ms"] = result.QueryTime, + ["wall_clock_ms"] = result.WallClockTime, + ["lock_wait_ms"] = result.LockWaitTime, + ["connect_ms"] = result.ConnectTime, + ["roundtrip_ms"] = result.IpcRoundTripTime, + ["is_enabled"] = result.IsEnabled, + ["category"] = result.Category, + ["registry_entry_count"] = result.RegistryEntries?.Count ?? 0 + }; } } } diff --git a/ContextMenuProfiler.UI/Core/BenchmarkStatistics.cs b/ContextMenuProfiler.UI/Core/BenchmarkStatistics.cs new file mode 100644 index 0000000..8b24579 --- /dev/null +++ b/ContextMenuProfiler.UI/Core/BenchmarkStatistics.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace ContextMenuProfiler.UI.Core +{ + public readonly record struct BenchmarkStatistics( + int TotalExtensions, + int DisabledExtensions, + long TotalLoadTime, + long ActiveLoadTime, + long DisabledLoadTime, + long RealLoadTimeMs) + { + public int ActiveExtensions => TotalExtensions - DisabledExtensions; + } + + public static class BenchmarkStatisticsCalculator + { + public static BenchmarkStatistics Calculate(IEnumerable results) + { + if (results == null) + { + return default; + } + + int totalExtensions = 0; + int disabledExtensions = 0; + long totalLoadTime = 0; + long activeLoadTime = 0; + long disabledLoadTime = 0; + long realLoadTimeMs = 0; + + foreach (var result in results) + { + totalExtensions++; + totalLoadTime += result.TotalTime; + + if (result.IsEnabled) + { + activeLoadTime += result.TotalTime; + realLoadTimeMs += result.WallClockTime > 0 + ? result.WallClockTime + : Math.Max(0, result.TotalTime); + } + else + { + disabledExtensions++; + disabledLoadTime += result.TotalTime; + } + } + + return new BenchmarkStatistics( + totalExtensions, + disabledExtensions, + totalLoadTime, + activeLoadTime, + disabledLoadTime, + realLoadTimeMs); + } + } +} diff --git a/ContextMenuProfiler.UI/Core/ComRegistrySemantics.cs b/ContextMenuProfiler.UI/Core/ComRegistrySemantics.cs new file mode 100644 index 0000000..6363743 --- /dev/null +++ b/ContextMenuProfiler.UI/Core/ComRegistrySemantics.cs @@ -0,0 +1,68 @@ +using System; + +namespace ContextMenuProfiler.UI.Core +{ + public static class ComRegistrySemantics + { + public const string FriendlyNameValueName = "FriendlyName"; + public const string InprocServer32SubKeyName = "InprocServer32"; + public const string ThreadingModelValueName = "ThreadingModel"; + public const string TreatAsSubKeyName = "TreatAs"; + public const string AppIdValueName = "AppID"; + public const string AppIdSubKeyPrefix = "AppID"; + public const string DllSurrogateValueName = "DllSurrogate"; + public const string DllHostExecutableName = "dllhost.exe"; + public const string DisplayNameValueName = "DisplayName"; + public const string PackageInstallPathValueName = "Path"; + public const string DllPathValueName = "DllPath"; + public const string ClsidPrefix = "CLSID"; + public const string Wow6432NodeClsidPrefix = @"WOW6432Node\CLSID"; + + public const string PackagedComClassIndexPrefix = @"PackagedCom\ClassIndex"; + public const string PackagedComPackagePrefix = @"PackagedCom\Package"; + public const string PackagedComClassSubKeyName = "Class"; + public const string PackageRepositoryPrefix = @"Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\PackageRepository\Packages"; + public const char PackageNameSeparator = '_'; + + public static string BuildPackagedComClassIndexPath(string clsidB) + { + return $@"{PackagedComClassIndexPrefix}\{clsidB}"; + } + + public static string BuildClsidPath(string clsidB) + { + return $@"{ClsidPrefix}\{clsidB}"; + } + + public static string BuildWow6432NodeClsidPath(string clsidB) + { + return $@"{Wow6432NodeClsidPrefix}\{clsidB}"; + } + + public static string BuildPackagedComPackageClassPath(string packageName, string clsidB) + { + return $@"{packageName}\{PackagedComClassSubKeyName}\{clsidB}"; + } + + public static string BuildAppIdPath(string appId) + { + return $@"{AppIdSubKeyPrefix}\{appId}"; + } + + public static string BuildPackageRepositoryPath(string packageFullName) + { + return $@"{PackageRepositoryPrefix}\{packageFullName}"; + } + + public static string ExtractPackageIdPrefix(string packageFullName) + { + int separatorIndex = packageFullName.IndexOf(PackageNameSeparator); + if (separatorIndex <= 0) + { + return packageFullName; + } + + return packageFullName[..separatorIndex]; + } + } +} diff --git a/ContextMenuProfiler.UI/Core/ExtensionManager.cs b/ContextMenuProfiler.UI/Core/ExtensionManager.cs index 79954f0..eee6e17 100644 --- a/ContextMenuProfiler.UI/Core/ExtensionManager.cs +++ b/ContextMenuProfiler.UI/Core/ExtensionManager.cs @@ -61,9 +61,9 @@ public static void DisableRegistryKey(string registryPath) string parentPath = registryPath.Substring(0, lastSlash); string keyName = registryPath.Substring(lastSlash + 1); - if (keyName.StartsWith("-")) return; // Already disabled + if (keyName.StartsWith(BenchmarkSemantics.RegistryLocationToken.StaticVerbDisabledKeyPrefix, StringComparison.Ordinal)) return; // Already disabled - RenameRegistryKey(parentPath, keyName, "-" + keyName); + RenameRegistryKey(parentPath, keyName, BenchmarkSemantics.RegistryLocationToken.StaticVerbDisabledKeyPrefix + keyName); } public static void EnableRegistryKey(string registryPath) @@ -75,9 +75,9 @@ public static void EnableRegistryKey(string registryPath) string parentPath = registryPath.Substring(0, lastSlash); string keyName = registryPath.Substring(lastSlash + 1); - if (!keyName.StartsWith("-")) return; // Already enabled + if (!keyName.StartsWith(BenchmarkSemantics.RegistryLocationToken.StaticVerbDisabledKeyPrefix, StringComparison.Ordinal)) return; // Already enabled - RenameRegistryKey(parentPath, keyName, keyName.Substring(1)); + RenameRegistryKey(parentPath, keyName, keyName.Substring(BenchmarkSemantics.RegistryLocationToken.StaticVerbDisabledKeyPrefix.Length)); } public static void DeleteRegistryKey(string registryPath) diff --git a/ContextMenuProfiler.UI/Core/Helpers/RegistryPathHelper.cs b/ContextMenuProfiler.UI/Core/Helpers/RegistryPathHelper.cs new file mode 100644 index 0000000..c7dc0c8 --- /dev/null +++ b/ContextMenuProfiler.UI/Core/Helpers/RegistryPathHelper.cs @@ -0,0 +1,31 @@ +using System; + +namespace ContextMenuProfiler.UI.Core.Helpers +{ + public static class RegistryPathHelper + { + public const string ClassesRootPrefix = "HKEY_CLASSES_ROOT\\"; + private const string AnyFileWildcardPrefix = "*\\"; + private const string HiveTokenPrefix = "HKEY_"; + + public static string NormalizeForRegedit(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + if (path.StartsWith(AnyFileWildcardPrefix, StringComparison.Ordinal)) + { + return ClassesRootPrefix + path; + } + + if (!path.Contains(HiveTokenPrefix, StringComparison.OrdinalIgnoreCase)) + { + return ClassesRootPrefix + path; + } + + return path; + } + } +} diff --git a/ContextMenuProfiler.UI/Core/Helpers/ShellUtils.cs b/ContextMenuProfiler.UI/Core/Helpers/ShellUtils.cs index 81ed3bd..4553329 100644 --- a/ContextMenuProfiler.UI/Core/Helpers/ShellUtils.cs +++ b/ContextMenuProfiler.UI/Core/Helpers/ShellUtils.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Text; using Microsoft.Win32; +using ContextMenuProfiler.UI.Core; namespace ContextMenuProfiler.UI.Core.Helpers { @@ -15,9 +16,13 @@ public static class ShellUtils /// public static string ResolveMuiString(string? muiString) { - if (string.IsNullOrEmpty(muiString) || !muiString.StartsWith("@")) return muiString ?? ""; + if (string.IsNullOrEmpty(muiString) + || !muiString.StartsWith(BenchmarkSemantics.IconLocation.IndirectStringPrefix, StringComparison.Ordinal)) + { + return muiString ?? ""; + } - var sb = new StringBuilder(1024); + var sb = new StringBuilder(BenchmarkSemantics.IconLocation.IndirectStringBufferSize); if (SHLoadIndirectString(muiString, sb, (uint)sb.Capacity, IntPtr.Zero) == 0) { return sb.ToString(); @@ -32,8 +37,8 @@ public static string ResolveMuiString(string? muiString) { // HKCR is a merged view of HKLM\SOFTWARE\Classes and HKCU\SOFTWARE\Classes. // We only need to check HKCR and then WOW6432Node specifically if not found. - return Registry.ClassesRoot.OpenSubKey($@"CLSID\{clsidB}") - ?? Registry.ClassesRoot.OpenSubKey($@"WOW6432Node\CLSID\{clsidB}"); + return Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.BuildClsidPath(clsidB)) + ?? Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.BuildWow6432NodeClsidPath(clsidB)); } } } diff --git a/ContextMenuProfiler.UI/Core/HookIpcClient.cs b/ContextMenuProfiler.UI/Core/HookIpcClient.cs index a8b8a9e..43f7fb3 100644 --- a/ContextMenuProfiler.UI/Core/HookIpcClient.cs +++ b/ContextMenuProfiler.UI/Core/HookIpcClient.cs @@ -1,18 +1,20 @@ using System; using System.IO; using System.IO.Pipes; +using System.Buffers.Binary; using System.Text; using System.Threading.Tasks; using System.Threading; using System.Diagnostics; -using System.Linq; using System.Text.Json; +using ContextMenuProfiler.UI.Core.Services; namespace ContextMenuProfiler.UI.Core { public class HookResponse { public bool success { get; set; } + public string? code { get; set; } public string? @interface { get; set; } public string? names { get; set; } public string? icons { get; set; } @@ -32,152 +34,60 @@ public class HookCallResult public long connect_ms { get; set; } public long roundtrip_ms { get; set; } public long total_ms { get; set; } + public string? ipc_error { get; set; } } public static class HookIpcClient { - private const string PipeName = "ContextMenuProfilerHook"; - private const int ConnectTimeoutMs = 1200; - private const int RoundTripTimeoutMs = 2000; - internal static readonly SemaphoreSlim IpcLock = new SemaphoreSlim(3, 3); + internal static readonly SemaphoreSlim IpcLock = new SemaphoreSlim( + HookIpcSemantics.Runtime.MaxConcurrentCalls, + HookIpcSemantics.Runtime.MaxConcurrentCalls); - public static async Task GetHookDataAsync(string clsid, string? contextPath = null, string? dllHint = null) + public static async Task GetHookDataAsync(string clsid, string? contextPath = null, string? dllHint = null, string? scanId = null) { var result = new HookCallResult(); var swTotal = Stopwatch.StartNew(); + int attempts = 0; try { // Default bait path if none provided - string path = contextPath ?? Path.Combine(Path.GetTempPath(), "ContextMenuProfiler_probe.txt"); + string path = contextPath ?? Path.Combine(Path.GetTempPath(), HookIpcSemantics.Runtime.ProbeFileName); if (!File.Exists(path) && !Directory.Exists(path)) { - try { File.WriteAllText(path, "probe"); } catch {} + try { File.WriteAllText(path, HookIpcSemantics.Runtime.ProbeFileContent); } catch {} } - for (int attempt = 0; attempt < 2; attempt++) + for (int attempt = 0; attempt < HookIpcSemantics.Runtime.MaxAttempts; attempt++) { - bool hasLock = false; + attempts = attempt + 1; try { - var swLock = Stopwatch.StartNew(); - await IpcLock.WaitAsync(); - hasLock = true; - swLock.Stop(); - result.lock_wait_ms += Math.Max(0, (long)swLock.Elapsed.TotalMilliseconds); - - using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) + if (await TryProbeWithLockAsync(clsid, path, dllHint, result, scanId, attempt + 1)) { - var swConnect = Stopwatch.StartNew(); - try - { - await client.ConnectAsync(ConnectTimeoutMs); - } - catch (Exception ex) - { - swConnect.Stop(); - result.connect_ms += Math.Max(0, (long)swConnect.Elapsed.TotalMilliseconds); - Debug.WriteLine($"[IPC DIAG] Connection Failed: {ex.Message}"); - if (attempt == 0) - { - await Task.Delay(80); - continue; - } - return result; - } - swConnect.Stop(); - result.connect_ms += Math.Max(0, (long)swConnect.Elapsed.TotalMilliseconds); - - var swRoundTrip = Stopwatch.StartNew(); - using var roundTripCts = new CancellationTokenSource(RoundTripTimeoutMs); - // Format: "CLSID|Path[|DllHint]" - string requestStr = string.IsNullOrEmpty(dllHint) ? $"{clsid}|{path}" : $"{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; - try - { - read = await client.ReadAsync(responseBuf, 0, responseBuf.Length, roundTripCts.Token); - } - catch (OperationCanceledException) - { - swRoundTrip.Stop(); - result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); - if (attempt == 0) - { - await Task.Delay(80); - continue; - } - return result; - } - - if (read <= 0) - { - swRoundTrip.Stop(); - result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); - if (attempt == 0) - { - await Task.Delay(80); - continue; - } - 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 = response.Substring(start, end - start + 1); - result.data = JsonSerializer.Deserialize(json); - swRoundTrip.Stop(); - result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); - return result; - } - swRoundTrip.Stop(); - result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); - if (attempt == 0) - { - await Task.Delay(80); - continue; - } - return result; - } - catch (JsonException) - { - swRoundTrip.Stop(); - result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); - if (attempt == 0) - { - await Task.Delay(80); - continue; - } - return result; - } + return result; } } catch (Exception ex) { - Debug.WriteLine($"[IPC DIAG] Critical Error: {ex.Message}"); - if (attempt == 0) - { - await Task.Delay(80); - continue; - } - return result; + result.ipc_error = "critical_error"; + LogService.Instance.ErrorEvent( + "ipc.probe_critical_error", + ex: ex, + fields: new Dictionary + { + ["scan_id"] = scanId, + ["clsid"] = clsid, + ["attempt"] = attempt + 1, + ["ipc_error"] = result.ipc_error + }); } - finally + + if (await DelayForRetryAsync(attempt)) { - if (hasLock) - { - IpcLock.Release(); - } + continue; } + + return result; } return result; } @@ -185,6 +95,160 @@ public static async Task GetHookDataAsync(string clsid, string? { swTotal.Stop(); result.total_ms = Math.Max(0, (long)swTotal.Elapsed.TotalMilliseconds); + + LogService.Instance.InfoEvent( + "ipc.probe_completed", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["clsid"] = clsid, + ["attempts"] = attempts, + ["success"] = result.data?.success == true, + ["probe_outcome"] = ResolveProbeOutcome(result), + ["hook_success"] = result.data?.success, + ["hook_code"] = result.data?.code, + ["hook_error"] = result.data?.error, + ["ipc_error"] = result.ipc_error, + ["lock_wait_ms"] = result.lock_wait_ms, + ["connect_ms"] = result.connect_ms, + ["roundtrip_ms"] = result.roundtrip_ms, + ["total_ms"] = result.total_ms + }); + } + } + + private static async Task TryProbeWithLockAsync(string clsid, string path, string? dllHint, HookCallResult result, string? scanId, int attempt) + { + bool hasLock = false; + var swLock = Stopwatch.StartNew(); + try + { + await IpcLock.WaitAsync(); + hasLock = true; + swLock.Stop(); + result.lock_wait_ms += Math.Max(0, (long)swLock.Elapsed.TotalMilliseconds); + return await TryProbeOnceAsync(clsid, path, dllHint, result, scanId, attempt); + } + finally + { + if (swLock.IsRunning) + { + swLock.Stop(); + } + + if (hasLock) + { + IpcLock.Release(); + } + } + } + + private static async Task TryProbeOnceAsync(string clsid, string path, string? dllHint, HookCallResult result, string? scanId, int attempt) + { + using var client = new NamedPipeClientStream(".", HookIpcSemantics.Runtime.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + + var swConnect = Stopwatch.StartNew(); + try + { + await client.ConnectAsync(HookIpcSemantics.Runtime.ConnectTimeoutMs); + } + catch (Exception ex) + { + swConnect.Stop(); + result.connect_ms += Math.Max(0, (long)swConnect.Elapsed.TotalMilliseconds); + result.ipc_error = "connect_failed"; + LogService.Instance.WarningEvent( + "ipc.probe_connect_failed", + ex: ex, + fields: new Dictionary + { + ["scan_id"] = scanId, + ["clsid"] = clsid, + ["attempt"] = attempt, + ["connect_ms"] = result.connect_ms, + ["ipc_error"] = result.ipc_error + }); + return false; + } + + swConnect.Stop(); + result.connect_ms += Math.Max(0, (long)swConnect.Elapsed.TotalMilliseconds); + + string requestStr = BuildRequest(clsid, path, dllHint); + byte[] requestPayload = Encoding.UTF8.GetBytes(requestStr); + return await TryProbeFramedAsync(client, requestPayload, result, scanId, clsid, attempt); + } + + private static async Task TryProbeFramedAsync(NamedPipeClientStream client, byte[] requestPayload, HookCallResult result, string? scanId, string clsid, int attempt) + { + var swRoundTrip = Stopwatch.StartNew(); + using var roundTripCts = new CancellationTokenSource(HookIpcSemantics.Runtime.RoundTripTimeoutMs); + byte[] responsePayload; + + try + { + await WriteFrameAsync(client, requestPayload, roundTripCts.Token); + responsePayload = await ReadFrameAsync(client, roundTripCts.Token); + } + catch (Exception) + { + result.ipc_error = "framed_exchange_failed"; + CompleteRoundTrip(swRoundTrip, result); + + LogService.Instance.WarningEvent( + "ipc.probe_framed_exchange_failed", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["clsid"] = clsid, + ["attempt"] = attempt, + ["roundtrip_ms"] = result.roundtrip_ms, + ["ipc_error"] = result.ipc_error + }); + return false; + } + + string response = Encoding.UTF8.GetString(responsePayload).TrimEnd('\0'); + if (!TryDeserializeHookResponse(response, out var parsedResponse)) + { + result.ipc_error = "invalid_json_response"; + CompleteRoundTrip(swRoundTrip, result); + + LogService.Instance.WarningEvent( + "ipc.probe_invalid_json_response", + fields: new Dictionary + { + ["scan_id"] = scanId, + ["clsid"] = clsid, + ["attempt"] = attempt, + ["roundtrip_ms"] = result.roundtrip_ms, + ["ipc_error"] = result.ipc_error + }); + return false; + } + + result.ipc_error = null; + result.data = parsedResponse; + CompleteRoundTrip(swRoundTrip, result); + return true; + } + + private static bool TryDeserializeHookResponse(string response, out HookResponse? parsed) + { + parsed = null; + if (string.IsNullOrWhiteSpace(response)) + { + return false; + } + + try + { + parsed = JsonSerializer.Deserialize(response); + return parsed != null; + } + catch (JsonException) + { + return false; } } @@ -194,7 +258,117 @@ public static async Task GetMenuNamesAsync(string clsid, string? conte var call = await GetHookDataAsync(clsid, contextPath); var data = call.data; if (data == null || !data.success || string.IsNullOrEmpty(data.names)) return Array.Empty(); - return data.names.Split('|', StringSplitOptions.RemoveEmptyEntries); + return data.names.Split(HookIpcSemantics.Response.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries); + } + + private static string ResolveProbeOutcome(HookCallResult result) + { + if (!string.IsNullOrWhiteSpace(result.ipc_error)) + { + return "ipc_error"; + } + + if (result.data == null) + { + return "unknown"; + } + + return result.data.success ? "ok" : "hook_error"; + } + + private static bool ShouldRetry(int attempt) + { + return attempt < HookIpcSemantics.Runtime.MaxAttempts - 1; + } + + private static async Task DelayForRetryAsync(int attempt) + { + if (!ShouldRetry(attempt)) + { + return false; + } + + await Task.Delay(HookIpcSemantics.Runtime.RetryDelayMs); + return true; + } + + private static void CompleteRoundTrip(Stopwatch swRoundTrip, HookCallResult result) + { + swRoundTrip.Stop(); + result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); + } + + internal static string BuildRequest(string clsid, string path, string? dllHint) + { + string header = + $"{HookIpcSemantics.Protocol.VersionPrefix}{HookIpcSemantics.Protocol.FieldDelimiter}{HookIpcSemantics.Protocol.ModeAuto}"; + + if (string.IsNullOrEmpty(dllHint)) + { + return $"{header}{HookIpcSemantics.Protocol.FieldDelimiter}{clsid}{HookIpcSemantics.Protocol.FieldDelimiter}{path}"; + } + + return $"{header}{HookIpcSemantics.Protocol.FieldDelimiter}{clsid}{HookIpcSemantics.Protocol.FieldDelimiter}{path}{HookIpcSemantics.Protocol.FieldDelimiter}{dllHint}"; + } + + private static async Task WriteFrameAsync(NamedPipeClientStream client, byte[] payload, CancellationToken token) + { + if (payload.Length > HookIpcSemantics.Runtime.MaxRequestBytes) + { + throw new InvalidDataException("IPC request is too large."); + } + + byte[] header = new byte[HookIpcSemantics.Runtime.FrameHeaderBytes]; + BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length); + + await client.WriteAsync(header, 0, header.Length, token); + await client.WriteAsync(payload, 0, payload.Length, token); + await client.FlushAsync(token); + } + + private static async Task ReadFrameAsync(NamedPipeClientStream client, CancellationToken token) + { + byte[] header = new byte[HookIpcSemantics.Runtime.FrameHeaderBytes]; + if (!await ReadExactAsync(client, header, header.Length, token)) + { + return Array.Empty(); + } + + int payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header); + if (payloadLength < 0 || payloadLength > HookIpcSemantics.Runtime.MaxResponseBytes) + { + throw new InvalidDataException("IPC response length is invalid."); + } + + if (payloadLength == 0) + { + return Array.Empty(); + } + + byte[] payload = new byte[payloadLength]; + if (!await ReadExactAsync(client, payload, payloadLength, token)) + { + throw new EndOfStreamException("IPC response ended before the frame payload was fully read."); + } + + return payload; + } + + private static async Task ReadExactAsync(Stream stream, byte[] buffer, int bytesToRead, CancellationToken token) + { + int offset = 0; + while (offset < bytesToRead) + { + int read = await stream.ReadAsync(buffer, offset, bytesToRead - offset, token); + if (read <= 0) + { + return false; + } + + offset += read; + } + + return true; } } } diff --git a/ContextMenuProfiler.UI/Core/HookIpcSemantics.cs b/ContextMenuProfiler.UI/Core/HookIpcSemantics.cs new file mode 100644 index 0000000..921f198 --- /dev/null +++ b/ContextMenuProfiler.UI/Core/HookIpcSemantics.cs @@ -0,0 +1,33 @@ +namespace ContextMenuProfiler.UI.Core +{ + public static class HookIpcSemantics + { + public static class Protocol + { + public const string VersionPrefix = "CMP1"; + public const string ModeAuto = "AUTO"; + public const char FieldDelimiter = '|'; + } + + public static class Runtime + { + public const string PipeName = "ContextMenuProfilerHook"; + public const int ConnectTimeoutMs = 1200; + public const int RoundTripTimeoutMs = 2000; + public const int MaxConcurrentCalls = 3; + public const int MaxAttempts = 2; + public const int RetryDelayMs = 80; + public const string ProbeFileName = "ContextMenuProfiler_probe.txt"; + public const string ProbeFileContent = "probe"; + public const int FrameHeaderBytes = 4; + public const int MaxRequestBytes = 16384; + public const int MaxResponseBytes = 65536; + } + + public static class Response + { + public const char MultiValueDelimiter = '|'; + public const string NoIconToken = "NONE"; + } + } +} diff --git a/ContextMenuProfiler.UI/Core/PackageManifestSemantics.cs b/ContextMenuProfiler.UI/Core/PackageManifestSemantics.cs new file mode 100644 index 0000000..c794abc --- /dev/null +++ b/ContextMenuProfiler.UI/Core/PackageManifestSemantics.cs @@ -0,0 +1,48 @@ +using System.Xml.Linq; + +namespace ContextMenuProfiler.UI.Core +{ + public static class PackageManifestSemantics + { + public static class NamespaceUri + { + public const string Default = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"; + public const string Uap = "http://schemas.microsoft.com/appx/manifest/uap/windows10"; + public const string Desktop4 = "http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"; + public const string Desktop5 = "http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"; + public const string Com = "http://schemas.microsoft.com/appx/manifest/com/windows10"; + } + + public static class Namespaces + { + public static readonly XNamespace Default = NamespaceUri.Default; + public static readonly XNamespace Uap = NamespaceUri.Uap; + public static readonly XNamespace Desktop4 = NamespaceUri.Desktop4; + public static readonly XNamespace Desktop5 = NamespaceUri.Desktop5; + public static readonly XNamespace Com = NamespaceUri.Com; + } + + public static class Manifest + { + public const string FileName = "AppxManifest.xml"; + public const string ContextMenuCategoryToken = "fileExplorerContextMenus"; + public const string ContextMenuCategory = "windows.fileExplorerContextMenus"; + + public const string ExtensionElement = "Extension"; + public const string ItemTypeElement = "ItemType"; + public const string VerbElement = "Verb"; + public const string VisualElementsElement = "VisualElements"; + public const string LogoElement = "Logo"; + public const string ClassElement = "Class"; + + public const string CategoryAttribute = "Category"; + public const string TypeAttribute = "Type"; + public const string ClsidAttribute = "Clsid"; + public const string IdAttribute = "Id"; + public const string PathAttribute = "Path"; + public const string Square44LogoAttribute = "Square44x44Logo"; + public const string Square150LogoAttribute = "Square150x150Logo"; + } + + } +} diff --git a/ContextMenuProfiler.UI/Core/PackageScanner.cs b/ContextMenuProfiler.UI/Core/PackageScanner.cs index c57d65a..d60360d 100644 --- a/ContextMenuProfiler.UI/Core/PackageScanner.cs +++ b/ContextMenuProfiler.UI/Core/PackageScanner.cs @@ -11,11 +11,7 @@ namespace ContextMenuProfiler.UI.Core { public class PackageScanner { - private static readonly XNamespace NS_DEFAULT = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"; - private static readonly XNamespace NS_UAP = "http://schemas.microsoft.com/appx/manifest/uap/windows10"; - private static readonly XNamespace NS_DESKTOP4 = "http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"; - private static readonly XNamespace NS_DESKTOP5 = "http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"; - private static readonly XNamespace NS_COM = "http://schemas.microsoft.com/appx/manifest/com/windows10"; + private static readonly XNamespace NS_COM = PackageManifestSemantics.Namespaces.Com; public static IEnumerable ScanPackagedExtensions(string? targetPath) { @@ -51,11 +47,11 @@ private static void ProcessPackage(Package package, List result string? installPath = package.InstalledLocation?.Path; if (string.IsNullOrEmpty(installPath)) return; - string manifestPath = Path.Combine(installPath, "AppxManifest.xml"); + string manifestPath = Path.Combine(installPath, PackageManifestSemantics.Manifest.FileName); if (!File.Exists(manifestPath)) return; string manifestContent = File.ReadAllText(manifestPath); - if (!manifestContent.Contains("fileExplorerContextMenus")) return; + if (!manifestContent.Contains(PackageManifestSemantics.Manifest.ContextMenuCategoryToken)) return; // Handle Sparse Packages (like VS Code) // Deferred: only resolve EffectiveLocation for packages that actually have context menu extensions @@ -67,8 +63,9 @@ private static void ProcessPackage(Package package, List result XDocument doc = XDocument.Parse(manifestContent); var clsidToPath = MapClsidToBinaryPath(doc, effectivePath); - var extensions = doc.Descendants().Where(e => e.Name.LocalName == "Extension" && - e.Attribute("Category")?.Value == "windows.fileExplorerContextMenus"); + var extensions = doc.Descendants().Where(e => + e.Name.LocalName == PackageManifestSemantics.Manifest.ExtensionElement + && e.Attribute(PackageManifestSemantics.Manifest.CategoryAttribute)?.Value == PackageManifestSemantics.Manifest.ContextMenuCategory); foreach (var extElement in extensions) { @@ -79,16 +76,16 @@ private static void ProcessPackage(Package package, List result private static void ProcessExtensionElement(Package package, XElement extElement, List results, Dictionary clsidToPath, string installPath, string targetExt, bool scanAll) { - var itemTypes = extElement.Descendants().Where(e => e.Name.LocalName == "ItemType"); + var itemTypes = extElement.Descendants().Where(e => e.Name.LocalName == PackageManifestSemantics.Manifest.ItemTypeElement); foreach (var itemType in itemTypes) { - string? type = itemType.Attribute("Type")?.Value?.ToLower(); + string? type = itemType.Attribute(PackageManifestSemantics.Manifest.TypeAttribute)?.Value?.ToLowerInvariant(); if (string.IsNullOrEmpty(type)) continue; if (!scanAll && !IsTypeMatch(type, targetExt)) continue; - var verbs = itemType.Descendants().Where(e => e.Name.LocalName == "Verb"); + var verbs = itemType.Descendants().Where(e => e.Name.LocalName == PackageManifestSemantics.Manifest.VerbElement); foreach (var verb in verbs) { if (TryParseVerb(package, verb, clsidToPath, installPath, out var result) && result != null) @@ -104,13 +101,13 @@ private static bool TryParseVerb(Package package, XElement verb, Dictionary e.Name.LocalName == "VisualElements"); + var visualElements = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == PackageManifestSemantics.Manifest.VisualElementsElement); if (visualElements != null) { - return visualElements.Attribute("Square44x44Logo")?.Value - ?? visualElements.Attribute("Square150x150Logo")?.Value - ?? visualElements.Attribute("Logo")?.Value; + return visualElements.Attribute(PackageManifestSemantics.Manifest.Square44LogoAttribute)?.Value + ?? visualElements.Attribute(PackageManifestSemantics.Manifest.Square150LogoAttribute)?.Value + ?? visualElements.Attribute(PackageManifestSemantics.Manifest.LogoElement)?.Value; } - return doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "Logo")?.Value; + return doc.Descendants().FirstOrDefault(e => e.Name.LocalName == PackageManifestSemantics.Manifest.LogoElement)?.Value; } private static Dictionary MapClsidToBinaryPath(XDocument doc, string installPath) { var map = new Dictionary(); - var classes = doc.Descendants().Where(e => e.Name.LocalName == "Class" && e.Name.Namespace == NS_COM); + var classes = doc.Descendants().Where(e => + e.Name.LocalName == PackageManifestSemantics.Manifest.ClassElement + && e.Name.Namespace == NS_COM); foreach (var cls in classes) { - string? idStr = cls.Attribute("Id")?.Value; - string? path = cls.Attribute("Path")?.Value; + string? idStr = cls.Attribute(PackageManifestSemantics.Manifest.IdAttribute)?.Value; + string? path = cls.Attribute(PackageManifestSemantics.Manifest.PathAttribute)?.Value; if (Guid.TryParse(idStr, out Guid guid) && !string.IsNullOrEmpty(path)) { map[guid] = path; @@ -199,24 +202,31 @@ private static IEnumerable SafeFindPackages(PackageManager pm) private static string DetermineTargetExtension(string? path) { - if (path == null) return ""; - if (Directory.Exists(path)) return "directory"; - return Path.GetExtension(path).ToLower(); + if (path == null) return string.Empty; + if (Directory.Exists(path)) return BenchmarkSemantics.RegistryPathPattern.DirectoryAssociationType; + return Path.GetExtension(path).ToLowerInvariant(); } private static bool IsTypeMatch(string type, string targetExt) { - if (type == "*") return true; - if (type == "directory" || type == "folder") return targetExt == "directory"; - if (type == "directory\\background") return targetExt == "directory"; - return type == targetExt; + if (string.Equals(type, BenchmarkSemantics.RegistryPathPattern.AnyAssociationType, StringComparison.Ordinal)) + { + return true; + } + + if (BenchmarkSemantics.RegistryPathPattern.IsDirectoryLikeAssociationType(type)) + { + return string.Equals(targetExt, BenchmarkSemantics.RegistryPathPattern.DirectoryAssociationType, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(type, targetExt, StringComparison.OrdinalIgnoreCase); } public static string? GetPackageNameForClsid(Guid clsid) { try { - using var key = Registry.ClassesRoot.OpenSubKey($@"PackagedCom\ClassIndex\{clsid:B}"); + using var key = Registry.ClassesRoot.OpenSubKey(ComRegistrySemantics.BuildPackagedComClassIndexPath(clsid.ToString("B"))); return key?.GetValue("") as string; } catch { return null; } diff --git a/ContextMenuProfiler.UI/Core/RegistryScanner.cs b/ContextMenuProfiler.UI/Core/RegistryScanner.cs index 5cd3138..b48a6f4 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; @@ -26,29 +27,55 @@ public class RegistryHandlerInfo public class RegistryScanner { - public static Dictionary> ScanHandlers(ScanMode mode = ScanMode.Targeted) + private static readonly (string Path, string Location)[] GlobalHandlerLocations = { - var handlers = new ConcurrentDictionary>(); + (BenchmarkSemantics.RegistryPathPattern.AllFilesHandlers, BenchmarkSemantics.RegistryLocationLabel.AllFiles), + (BenchmarkSemantics.RegistryPathPattern.AllFilesHandlersDisabled, BenchmarkSemantics.BuildDisabledRegistryLocationLabel(BenchmarkSemantics.RegistryLocationLabel.AllFiles)) + }; - // 1. Scan Global Locations (Fast & Essential) - var commonLocations = new[] - { - (@"*\shellex\ContextMenuHandlers", "All Files (*)"), - (@"*\shellex\-ContextMenuHandlers", "All Files (*) [Disabled]"), - (@"Directory\shellex\ContextMenuHandlers", "Directory"), - (@"Directory\shellex\-ContextMenuHandlers", "Directory [Disabled]"), - (@"Folder\shellex\ContextMenuHandlers", "Folder"), - (@"Drive\shellex\ContextMenuHandlers", "Drive"), - (@"AllFileSystemObjects\shellex\ContextMenuHandlers", "All File System Objects"), - (@"Directory\Background\shellex\ContextMenuHandlers", "Directory Background"), - (@"DesktopBackground\shellex\ContextMenuHandlers", "Desktop Background") - }; + private static readonly (string Path, string Location)[] DirectoryScopedHandlerLocations = + { + (BenchmarkSemantics.RegistryPathPattern.DirectoryHandlers, BenchmarkSemantics.RegistryLocationLabel.Directory), + (BenchmarkSemantics.RegistryPathPattern.DirectoryHandlersDisabled, BenchmarkSemantics.BuildDisabledRegistryLocationLabel(BenchmarkSemantics.RegistryLocationLabel.Directory)), + (BenchmarkSemantics.RegistryPathPattern.FolderHandlers, BenchmarkSemantics.RegistryLocationLabel.Folder), + (BenchmarkSemantics.RegistryPathPattern.DriveHandlers, BenchmarkSemantics.RegistryLocationLabel.Drive), + (BenchmarkSemantics.RegistryPathPattern.AllFileSystemObjectsHandlers, BenchmarkSemantics.RegistryLocationLabel.AllFileSystemObjects), + (BenchmarkSemantics.RegistryPathPattern.DirectoryBackgroundHandlers, BenchmarkSemantics.RegistryLocationLabel.DirectoryBackground), + (BenchmarkSemantics.RegistryPathPattern.DesktopBackgroundHandlers, BenchmarkSemantics.RegistryLocationLabel.DesktopBackground) + }; + + private static readonly (string Path, string Location)[] GlobalShellLocations = + { + (BenchmarkSemantics.RegistryPathPattern.AllFilesShell, BenchmarkSemantics.RegistryLocationLabel.AllFiles) + }; - foreach (var loc in commonLocations) + private static readonly (string Path, string Location)[] DirectoryScopedShellLocations = + { + (BenchmarkSemantics.RegistryPathPattern.DirectoryShell, BenchmarkSemantics.RegistryLocationLabel.Directory), + (BenchmarkSemantics.RegistryPathPattern.DirectoryBackgroundShell, BenchmarkSemantics.RegistryLocationLabel.DirectoryBackground), + (BenchmarkSemantics.RegistryPathPattern.DriveShell, BenchmarkSemantics.RegistryLocationLabel.Drive), + (BenchmarkSemantics.RegistryPathPattern.FolderShell, BenchmarkSemantics.RegistryLocationLabel.Folder) + }; + + private readonly struct TargetAssociationContext + { + public TargetAssociationContext(bool isDirectory, string associationType) { - ScanLocation(handlers, loc.Item1, loc.Item2); + IsDirectory = isDirectory; + AssociationType = associationType; } + public bool IsDirectory { get; } + public string AssociationType { get; } + } + + public static Dictionary> ScanHandlers(ScanMode mode = ScanMode.Targeted) + { + var handlers = new ConcurrentDictionary>(); + + ScanHandlerLocations(handlers, GlobalHandlerLocations); + ScanHandlerLocations(handlers, DirectoryScopedHandlerLocations); + // 2. Scan Extensions (Only if Full mode) if (mode == ScanMode.Full) { @@ -62,19 +89,21 @@ public static Dictionary> ScanHandlers(ScanMode Parallel.ForEach(rootKeys, options, keyName => { - if (keyName.StartsWith(".")) + if (keyName.StartsWith(BenchmarkSemantics.RegistryToken.ExtensionPrefix, StringComparison.Ordinal)) { // It's an extension // Check SystemFileAssociations - ScanLocation(handlers, $"SystemFileAssociations\\{keyName}\\shellex\\ContextMenuHandlers", $"Extension ({keyName})"); - ScanLocation(handlers, $"SystemFileAssociations\\{keyName}\\shellex\\-ContextMenuHandlers", $"Extension ({keyName}) [Disabled]"); + string extensionLocation = BenchmarkSemantics.BuildExtensionRegistryLocationLabel(keyName); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildSystemFileAssociationHandlers(keyName, disabled: false), extensionLocation); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildSystemFileAssociationHandlers(keyName, disabled: true), BenchmarkSemantics.BuildDisabledRegistryLocationLabel(extensionLocation)); // Get ProgID string? progId = GetProgID(keyName); if (!string.IsNullOrEmpty(progId)) { - ScanLocation(handlers, $"{progId}\\shellex\\ContextMenuHandlers", $"ProgID ({progId} for {keyName})"); - ScanLocation(handlers, $"{progId}\\shellex\\-ContextMenuHandlers", $"ProgID ({progId} for {keyName}) [Disabled]"); + string progIdLocation = BenchmarkSemantics.BuildProgIdRegistryLocationLabel(progId, keyName); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildProgIdHandlers(progId, disabled: false), progIdLocation); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildProgIdHandlers(progId, disabled: true), BenchmarkSemantics.BuildDisabledRegistryLocationLabel(progIdLocation)); } } }); @@ -84,6 +113,24 @@ public static Dictionary> ScanHandlers(ScanMode return new Dictionary>(handlers); } + public static Dictionary> ScanHandlersForPath(string targetPath) + { + var handlers = new ConcurrentDictionary>(); + var context = ResolveTargetAssociationContext(targetPath); + + ScanHandlerLocations(handlers, GlobalHandlerLocations); + + if (context.IsDirectory) + { + ScanHandlerLocations(handlers, DirectoryScopedHandlerLocations); + return new Dictionary>(handlers); + } + + ScanFileAssociationHandlers(handlers, context.AssociationType); + + return new Dictionary>(handlers); + } + // Updated signature to accept ConcurrentDictionary private static void ScanLocation(ConcurrentDictionary> handlers, string subKeyPath, string locationName) { @@ -94,47 +141,23 @@ private static void ScanLocation(ConcurrentDictionary new List()); + lock (list) { - var info = new RegistryHandlerInfo { - Path = $@"{subKeyPath}\{subKeyName}", - Location = $"{locationName} ({trimmedName})" - }; - - var list = handlers.GetOrAdd(clsid, _ => new List()); - lock (list) + if (!list.Any(i => i.Path == info.Path)) { - if (!list.Any(i => i.Path == info.Path)) - { - list.Add(info); - } + list.Add(info); } } } @@ -146,6 +169,31 @@ private static void ScanLocation(ConcurrentDictionary> ScanStaticVerbs() { var verbs = new ConcurrentDictionary>(); - // 1. Scan Global Locations - var shellLocations = new[] - { - (@"*\shell", "All Files (*)"), - (@"Directory\shell", "Directory"), - (@"Directory\Background\shell", "Directory Background"), - (@"Drive\shell", "Drive"), - (@"Folder\shell", "Folder") - }; + ScanShellLocations(verbs, GlobalShellLocations); + ScanShellLocations(verbs, DirectoryScopedShellLocations); + + return new Dictionary>(verbs); + } + + public static Dictionary> ScanStaticVerbsForPath(string targetPath) + { + var verbs = new ConcurrentDictionary>(); + var context = ResolveTargetAssociationContext(targetPath); + + ScanShellLocations(verbs, GlobalShellLocations); - foreach (var loc in shellLocations) + if (context.IsDirectory) { - ScanShellKey(verbs, loc.Item1, loc.Item2); + ScanShellLocations(verbs, DirectoryScopedShellLocations); + return new Dictionary>(verbs); } + ScanFileAssociationShellVerbs(verbs, context.AssociationType); + return new Dictionary>(verbs); } + private static TargetAssociationContext ResolveTargetAssociationContext(string targetPath) + { + bool isDirectory = Directory.Exists(targetPath); + string associationType = isDirectory + ? BenchmarkSemantics.RegistryPathPattern.DirectoryAssociationType + : Path.GetExtension(targetPath).ToLowerInvariant(); + + return new TargetAssociationContext(isDirectory, associationType); + } + + private static void ScanHandlerLocations( + ConcurrentDictionary> handlers, + IEnumerable<(string Path, string Location)> locations) + { + foreach (var location in locations) + { + ScanLocation(handlers, location.Path, location.Location); + } + } + + private static void ScanShellLocations( + ConcurrentDictionary> verbs, + IEnumerable<(string Path, string Location)> locations) + { + foreach (var location in locations) + { + ScanShellKey(verbs, location.Path, location.Location); + } + } + + private static void ScanFileAssociationHandlers(ConcurrentDictionary> handlers, string extension) + { + if (string.IsNullOrEmpty(extension)) + { + return; + } + + string extensionLocation = BenchmarkSemantics.BuildExtensionRegistryLocationLabel(extension); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildSystemFileAssociationHandlers(extension, disabled: false), extensionLocation); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildSystemFileAssociationHandlers(extension, disabled: true), BenchmarkSemantics.BuildDisabledRegistryLocationLabel(extensionLocation)); + + string? progId = GetProgID(extension); + if (string.IsNullOrEmpty(progId)) + { + return; + } + + string progIdLocation = BenchmarkSemantics.BuildProgIdRegistryLocationLabel(progId, extension); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildProgIdHandlers(progId, disabled: false), progIdLocation); + ScanLocation(handlers, BenchmarkSemantics.RegistryPathPattern.BuildProgIdHandlers(progId, disabled: true), BenchmarkSemantics.BuildDisabledRegistryLocationLabel(progIdLocation)); + } + + private static void ScanFileAssociationShellVerbs(ConcurrentDictionary> verbs, string extension) + { + if (string.IsNullOrEmpty(extension)) + { + return; + } + + ScanShellKey(verbs, BenchmarkSemantics.RegistryPathPattern.BuildSystemFileAssociationShell(extension), BenchmarkSemantics.BuildExtensionRegistryLocationLabel(extension)); + + string? progId = GetProgID(extension); + if (string.IsNullOrEmpty(progId)) + { + return; + } + + ScanShellKey(verbs, BenchmarkSemantics.RegistryPathPattern.BuildProgIdShell(progId), BenchmarkSemantics.BuildProgIdRegistryLocationLabel(progId, extension)); + } + private static void ScanShellKey(ConcurrentDictionary> verbs, string subKeyPath, string locationName) { try @@ -194,8 +318,7 @@ private static void ScanShellKey(ConcurrentDictionary> verb foreach (var verbName in key.GetSubKeyNames()) { // Ignore some system defaults that are usually not interesting or dangerous to touch - if (verbName.Equals("Attributes", StringComparison.OrdinalIgnoreCase) || - verbName.Equals("AnyCode", StringComparison.OrdinalIgnoreCase)) continue; + if (BenchmarkSemantics.IsIgnoredStaticVerbName(verbName)) continue; using (var verbKey = key.OpenSubKey(verbName)) { @@ -203,7 +326,7 @@ private static void ScanShellKey(ConcurrentDictionary> verb // Get Command string command = ""; - using (var commandKey = verbKey.OpenSubKey("command")) + using (var commandKey = verbKey.OpenSubKey(BenchmarkSemantics.StaticVerb.CommandSubKeyName)) { command = commandKey?.GetValue("") as string ?? ""; } @@ -213,25 +336,26 @@ private static void ScanShellKey(ConcurrentDictionary> verb if (string.IsNullOrEmpty(command)) continue; // Get Display Name (MUIVerb > Default) - string? displayName = verbKey.GetValue("MUIVerb") as string; + string? displayName = verbKey.GetValue(BenchmarkSemantics.StaticVerb.MuiVerbValueName) as string; if (string.IsNullOrEmpty(displayName)) { displayName = verbKey.GetValue("") as string; // Default value } // Resolve MUI string if necessary - if (!string.IsNullOrEmpty(displayName) && displayName.StartsWith("@")) + if (!string.IsNullOrEmpty(displayName) + && displayName.StartsWith(BenchmarkSemantics.IconLocation.IndirectStringPrefix, StringComparison.Ordinal)) { displayName = ShellUtils.ResolveMuiString(displayName); } if (string.IsNullOrEmpty(displayName)) { - displayName = verbName.TrimStart('-'); // Fallback to key name + displayName = verbName.TrimStart(BenchmarkSemantics.RegistryLocationToken.StaticVerbDisabledKeyPrefixChar); // Fallback to key name } // Use unique key: "Name|Command" to distinguish same name but different command - string uniqueKey = $"{displayName}|{command}"; + string uniqueKey = BenchmarkSemantics.BuildStaticVerbUniqueKey(displayName, command); var list = verbs.GetOrAdd(uniqueKey, _ => new List()); lock (list) diff --git a/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs index caf51d3..2ac8df8 100644 --- a/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs +++ b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs @@ -160,6 +160,7 @@ private static Dictionary BuildEnglish() ["Dashboard.Label.Clsid"] = "CLSID:", ["Dashboard.Label.Binary"] = "Binary:", ["Dashboard.Label.Details"] = "Details:", + ["Dashboard.Label.Status"] = "Status: ", ["Dashboard.Label.Interface"] = "Interface: ", ["Dashboard.Label.IconSource"] = "Icon Source: ", ["Dashboard.Label.Threading"] = "Threading: ", @@ -167,7 +168,9 @@ private static Dictionary BuildEnglish() ["Dashboard.Label.Registry"] = "Registry:", ["Dashboard.Label.Package"] = "Package:", ["Dashboard.Value.Unknown"] = "Unknown", + ["Dashboard.Value.UnknownWithClsid"] = "Unknown ({0})", ["Dashboard.Value.None"] = "None", + ["Dashboard.Value.ManifestAppLogo"] = "Manifest (App Logo)", ["Dashboard.Action.Copy"] = "Copy", ["Dashboard.Action.DeletePermanently"] = "Delete Permanently", ["Dashboard.Sort.LoadDesc"] = "Load Time (High to Low)", @@ -180,12 +183,54 @@ private static Dictionary BuildEnglish() ["Dashboard.Status.ScanningFile"] = "Scanning: {0}", ["Dashboard.Status.ScanComplete"] = "Scan complete. Found {0} extensions.", ["Dashboard.Status.ScanFailed"] = "Scan failed.", + ["Dashboard.Status.Ok"] = "OK", + ["Dashboard.Status.VerifiedViaHook"] = "Verified via Hook", + ["Dashboard.Status.HookLoadedNoMenu"] = "Hook Loaded (No Menu)", + ["Dashboard.Status.OrphanedMissingDll"] = "Orphaned / Missing DLL", + ["Dashboard.Status.IpcTimeout"] = "IPC Timeout", + ["Dashboard.Status.LoadError"] = "Load Error", + ["Dashboard.Status.RegistryFallback"] = "Registry Fallback", + ["Dashboard.Status.StaticNotMeasured"] = "Static (Not Measured)", + ["Dashboard.Status.SkippedKnownUnstable"] = "Skipped (Known Unstable)", + ["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", ["Dashboard.Status.Unknown"] = "Unknown Status", + ["Dashboard.Detail.StaticNotMeasured"] = "Static shell verbs do not go through Hook COM probing and are displayed as not measured.", + ["Dashboard.Detail.SkippedKnownUnstable"] = "Skipped Hook invocation for a known unstable system handler to avoid scan-wide IPC stalls.", + ["Dashboard.Detail.OrphanedMissingDll"] = "The file '{0}' was not found on disk. This extension is likely corrupted or uninstalled.", + ["Dashboard.Detail.HookLoadedNoMenu"] = "The extension was loaded by the Hook service but it did not provide any context menu items for the test context.", + ["Dashboard.Detail.HookProbeTimeoutWithError"] = "Hook service timed out while probing this extension. Error: {0}", + ["Dashboard.Detail.HookLoadErrorWithError"] = "The Hook service failed to load this extension. Error: {0}", + ["Dashboard.Detail.HookResponseTimeoutFallback"] = "Hook service response timed out for this extension. Data is based on registry scan only.", + ["Dashboard.Detail.HookUnavailableFallback"] = "The Hook service could not be reached or failed to process this extension. Data is based on registry scan only.", ["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.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...", @@ -252,6 +297,7 @@ private static Dictionary BuildChinese() ["Dashboard.Label.Clsid"] = "CLSID:", ["Dashboard.Label.Binary"] = "二进制:", ["Dashboard.Label.Details"] = "详情:", + ["Dashboard.Label.Status"] = "状态:", ["Dashboard.Label.Interface"] = "接口:", ["Dashboard.Label.IconSource"] = "图标来源:", ["Dashboard.Label.Threading"] = "线程模型:", @@ -259,7 +305,9 @@ private static Dictionary BuildChinese() ["Dashboard.Label.Registry"] = "注册表:", ["Dashboard.Label.Package"] = "包:", ["Dashboard.Value.Unknown"] = "未知", + ["Dashboard.Value.UnknownWithClsid"] = "未知 ({0})", ["Dashboard.Value.None"] = "无", + ["Dashboard.Value.ManifestAppLogo"] = "清单(应用图标)", ["Dashboard.Action.Copy"] = "复制", ["Dashboard.Action.DeletePermanently"] = "永久删除", ["Dashboard.Sort.LoadDesc"] = "加载时间(高到低)", @@ -272,12 +320,54 @@ private static Dictionary BuildChinese() ["Dashboard.Status.ScanningFile"] = "正在扫描:{0}", ["Dashboard.Status.ScanComplete"] = "扫描完成,共找到 {0} 个扩展。", ["Dashboard.Status.ScanFailed"] = "扫描失败。", + ["Dashboard.Status.Ok"] = "正常", + ["Dashboard.Status.VerifiedViaHook"] = "已由 Hook 验证", + ["Dashboard.Status.HookLoadedNoMenu"] = "Hook 已加载(无菜单)", + ["Dashboard.Status.OrphanedMissingDll"] = "孤立 / 缺失 DLL", + ["Dashboard.Status.IpcTimeout"] = "IPC 超时", + ["Dashboard.Status.LoadError"] = "加载错误", + ["Dashboard.Status.RegistryFallback"] = "注册表回退", + ["Dashboard.Status.StaticNotMeasured"] = "静态(未测量)", + ["Dashboard.Status.SkippedKnownUnstable"] = "已跳过(已知不稳定)", + ["Dashboard.Status.DisabledPendingRestart"] = "已禁用(待重启)", + ["Dashboard.Status.EnabledPendingRestart"] = "已启用(待重启)", + ["Dashboard.Status.ReconnectingHook"] = "正在重连 Hook...", + ["Dashboard.Status.InitializingHook"] = "正在初始化 Hook 服务...", ["Dashboard.Status.Ready"] = "准备开始扫描", ["Dashboard.Status.Unknown"] = "未知状态", + ["Dashboard.Detail.StaticNotMeasured"] = "静态命令不会经过 Hook COM 探测流程,因此显示为未测量。", + ["Dashboard.Detail.SkippedKnownUnstable"] = "为避免扫描期间出现 IPC 卡顿,已跳过已知不稳定系统处理器的 Hook 探测。", + ["Dashboard.Detail.OrphanedMissingDll"] = "磁盘上未找到文件“{0}”。该扩展可能已损坏或被卸载。", + ["Dashboard.Detail.HookLoadedNoMenu"] = "该扩展已被 Hook 服务加载,但在当前测试上下文中未提供任何右键菜单项。", + ["Dashboard.Detail.HookProbeTimeoutWithError"] = "Hook 服务在探测该扩展时超时。错误:{0}", + ["Dashboard.Detail.HookLoadErrorWithError"] = "Hook 服务加载该扩展失败。错误:{0}", + ["Dashboard.Detail.HookResponseTimeoutFallback"] = "Hook 服务响应该扩展超时。当前数据仅基于注册表扫描。", + ["Dashboard.Detail.HookUnavailableFallback"] = "Hook 服务不可达或处理该扩展失败。当前数据仅基于注册表扫描。", ["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.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/Core/Services/LogService.cs b/ContextMenuProfiler.UI/Core/Services/LogService.cs index 8c748d2..5dc52fe 100644 --- a/ContextMenuProfiler.UI/Core/Services/LogService.cs +++ b/ContextMenuProfiler.UI/Core/Services/LogService.cs @@ -1,6 +1,11 @@ using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; using System.Text; +using System.Text.Json; +using System.Threading; namespace ContextMenuProfiler.UI.Core.Services { @@ -8,6 +13,14 @@ public class LogService { private static readonly string LogFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ContextMenuProfiler", "app.log"); private static readonly object LockObj = new object(); + private const long MaxLogFileBytes = 20L * 1024 * 1024; + private const long RetainAfterTrimBytes = 10L * 1024 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = false + }; + private static readonly ConcurrentDictionary ScanEventSequence = new ConcurrentDictionary(StringComparer.Ordinal); + private readonly AsyncLocal?> _scopeFields = new AsyncLocal?>(); public static LogService Instance { get; } = new LogService(); @@ -21,55 +34,255 @@ private LogService() catch { /* Best effort */ } } - public void Info(string message) => Log("INFO", message); + public void Info(string message) => WriteEvent("INFO", "app.info", message, null, null); + public void Warning(string message, Exception? ex = null) { - var sb = new StringBuilder(); - sb.AppendLine(message); - if (ex != null) - { - sb.AppendLine($"Exception: {ex.Message}"); - sb.AppendLine($"Stack Trace: {ex.StackTrace}"); - } - Log("WARN", sb.ToString()); + WriteEvent("WARN", "app.warning", message, ex, null); } public void Error(string message, Exception? ex = null) { - var sb = new StringBuilder(); - sb.AppendLine(message); - - Exception? currentEx = ex; - int level = 0; - while (currentEx != null) - { - if (level > 0) sb.AppendLine($"Inner Exception [{level}]: {currentEx.Message}"); - else sb.AppendLine($"Exception: {currentEx.Message}"); - - sb.AppendLine($"Stack Trace: {currentEx.StackTrace}"); - currentEx = currentEx.InnerException; - level++; + WriteEvent("ERROR", "app.error", message, ex, null); + } + + public void InfoEvent(string eventName, IReadOnlyDictionary? fields = null, string? message = null) + { + WriteEvent("INFO", eventName, message, null, fields); + } + + public void WarningEvent(string eventName, string? message = null, Exception? ex = null, IReadOnlyDictionary? fields = null) + { + WriteEvent("WARN", eventName, message, ex, fields); + } + + public void ErrorEvent(string eventName, string? message = null, Exception? ex = null, IReadOnlyDictionary? fields = null) + { + WriteEvent("ERROR", eventName, message, ex, fields); + } + + public IDisposable BeginScope(IReadOnlyDictionary fields) + { + var previous = _scopeFields.Value; + var merged = new Dictionary(StringComparer.Ordinal); + + if (previous != null) + { + foreach (var kv in previous) + { + merged[kv.Key] = kv.Value; + } } - - Log("ERROR", sb.ToString()); + + foreach (var kv in fields) + { + merged[kv.Key] = kv.Value; + } + + _scopeFields.Value = merged; + return new ScopeToken(this, previous); } - private void Log(string level, string message) + private void WriteEvent( + string level, + string eventName, + string? message, + Exception? ex, + IReadOnlyDictionary? fields) { try { + var payload = new Dictionary(StringComparer.Ordinal) + { + ["timestamp"] = DateTimeOffset.UtcNow.ToString("O"), + ["level"] = level, + ["event"] = eventName + }; + + if (!string.IsNullOrWhiteSpace(message)) + { + payload["message"] = message; + } + + if (fields != null) + { + foreach (var kv in fields) + { + payload[kv.Key] = NormalizeValue(kv.Value); + } + } + + if (_scopeFields.Value != null) + { + foreach (var kv in _scopeFields.Value) + { + if (!payload.ContainsKey(kv.Key)) + { + payload[kv.Key] = NormalizeValue(kv.Value); + } + } + } + + if (ex != null) + { + payload["exception"] = BuildExceptionObject(ex); + } + + if (payload.TryGetValue("scan_id", out object? scanIdValue) + && scanIdValue is string scanId + && !string.IsNullOrWhiteSpace(scanId)) + { + long seq = ScanEventSequence.AddOrUpdate(scanId, 1, static (_, current) => current + 1); + payload["event_seq"] = seq; + + if (string.Equals(eventName, "scan.session_completed", StringComparison.Ordinal) + || string.Equals(eventName, "scan.session_failed", StringComparison.Ordinal)) + { + ScanEventSequence.TryRemove(scanId, out _); + } + } + + string logEntry = JsonSerializer.Serialize(payload, JsonOptions); + lock (LockObj) { - string logEntry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] {message}"; + TrimLogFileIfNeeded(); File.AppendAllText(LogFile, logEntry + Environment.NewLine); - - // Also write to Debug output - System.Diagnostics.Debug.WriteLine(logEntry); } + + System.Diagnostics.Debug.WriteLine(logEntry); } catch (Exception) { - // Last resort: fail silently if logging fails + } + } + + private static void TrimLogFileIfNeeded() + { + if (!File.Exists(LogFile)) + { + return; + } + + try + { + var fileInfo = new FileInfo(LogFile); + if (fileInfo.Length <= MaxLogFileBytes) + { + return; + } + + using var stream = new FileStream(LogFile, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + if (stream.Length <= MaxLogFileBytes) + { + return; + } + + int bytesToKeep = (int)Math.Min(RetainAfterTrimBytes, stream.Length); + stream.Seek(-bytesToKeep, SeekOrigin.End); + + var buffer = new byte[bytesToKeep]; + int read = stream.Read(buffer, 0, buffer.Length); + if (read <= 0) + { + stream.SetLength(0); + return; + } + + string tailText = Encoding.UTF8.GetString(buffer, 0, read); + int firstLineBreak = tailText.IndexOf('\n'); + if (firstLineBreak >= 0 && firstLineBreak + 1 < tailText.Length) + { + tailText = tailText[(firstLineBreak + 1)..]; + } + + stream.SetLength(0); + stream.Position = 0; + byte[] output = Encoding.UTF8.GetBytes(tailText); + stream.Write(output, 0, output.Length); + stream.Flush(); + } + catch + { + } + } + + private static Dictionary BuildExceptionObject(Exception ex) + { + var chain = new List>(); + Exception? current = ex; + + while (current != null) + { + chain.Add(new Dictionary(StringComparer.Ordinal) + { + ["type"] = current.GetType().FullName, + ["message"] = current.Message, + ["stack_trace"] = current.StackTrace + }); + current = current.InnerException; + } + + return new Dictionary(StringComparer.Ordinal) + { + ["message"] = ex.Message, + ["type"] = ex.GetType().FullName, + ["chain"] = chain + }; + } + + private static object? NormalizeValue(object? value) + { + if (value == null) + { + return null; + } + + Type type = value.GetType(); + if (type.IsPrimitive || value is decimal || value is string || value is Guid || value is DateTime || value is DateTimeOffset || value is TimeSpan) + { + return value; + } + + if (value is Enum) + { + return value.ToString(); + } + + if (value is IEnumerable enumerable && value is not string) + { + var list = new List(); + foreach (var item in enumerable) + { + list.Add(NormalizeValue(item)); + } + return list; + } + + return value.ToString(); + } + + private sealed class ScopeToken : IDisposable + { + private readonly LogService _owner; + private readonly IReadOnlyDictionary? _previous; + private bool _disposed; + + public ScopeToken(LogService owner, IReadOnlyDictionary? previous) + { + _owner = owner; + _previous = previous; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _owner._scopeFields.Value = _previous; + _disposed = true; } } } diff --git a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs index 5532285..f623cb1 100644 --- a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Threading; @@ -31,10 +32,21 @@ public partial class CategoryItem : ObservableObject private bool _isActive; } - public partial class DashboardViewModel : ObservableObject + public partial class DashboardViewModel : ObservableObject, IDisposable { + private enum LastScanMode + { + None, + System, + File + } + private readonly BenchmarkService _benchmarkService; + private readonly PropertyChangedEventHandler _localizationChangedHandler; + private readonly PropertyChangedEventHandler _hookServiceChangedHandler; private CancellationTokenSource? _filterCts; + private bool _disposed; + private string? _currentScanId; [ObservableProperty] private ObservableCollection _displayResults = new(); @@ -56,7 +68,7 @@ partial void OnSearchTextChanged(string value) } [ObservableProperty] - private string _selectedCategory = "All"; + private string _selectedCategory = BenchmarkSemantics.FilterCategory.All; [ObservableProperty] private int _selectedCategoryIndex = 0; @@ -76,8 +88,14 @@ partial void OnSelectedCategoryChanged(string value) private async Task ApplyFilterAsync() { + if (_disposed) + { + return; + } + // Cancel previous filter task _filterCts?.Cancel(); + _filterCts?.Dispose(); _filterCts = new CancellationTokenSource(); var token = _filterCts.Token; @@ -100,7 +118,7 @@ private async Task ApplyFilterAsync() return query.ToList().OrderBy(r => r, new ComparisonComparer(comparer)).ToList(); }, token); - if (token.IsCancellationRequested) return; + if (token.IsCancellationRequested || _disposed) return; // 2. Clear current display DisplayResults.Clear(); @@ -131,7 +149,7 @@ private bool MatchesFilter(BenchmarkResult result) } // Category Match - bool categoryMatch = SelectedCategory == "All" || result.Category == SelectedCategory; + bool categoryMatch = BenchmarkSemantics.IsCategoryMatch(SelectedCategory, result.Category); if (!categoryMatch) return false; // Search Match @@ -192,9 +210,9 @@ 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 LastScanMode _lastScanMode = LastScanMode.None; private string _lastScanPath = ""; private long _scanOrderCounter = 0; @@ -222,73 +240,98 @@ public DashboardViewModel() _benchmarkService = new BenchmarkService(); // Removed sync ScanResultsView setup + _localizationChangedHandler = OnLocalizationChanged; + _hookServiceChangedHandler = OnHookServicePropertyChanged; + // Initialize categories ApplyLocalizedCategoryNames(); - LocalizationService.Instance.PropertyChanged += (_, e) => - { - if (e.PropertyName == "Item[]") - { - ApplyLocalizedCategoryNames(); - OnPropertyChanged(nameof(CurrentHookStatus)); - OnPropertyChanged(nameof(HookStatusMessage)); - if (!IsBusy) - { - StatusText = LocalizationService.Instance["Dashboard.Status.Ready"]; - } - } - }; + LocalizationService.Instance.PropertyChanged += _localizationChangedHandler; // Observe Hook status changes to update command availability - HookService.Instance.PropertyChanged += (s, e) => { - if (e.PropertyName == nameof(HookService.CurrentStatus)) - { - App.Current.Dispatcher.Invoke(() => { - OnPropertyChanged(nameof(CurrentHookStatus)); - OnPropertyChanged(nameof(HookStatusMessage)); - ScanSystemCommand.NotifyCanExecuteChanged(); - PickAndScanFileCommand.NotifyCanExecuteChanged(); - RefreshCommand.NotifyCanExecuteChanged(); - }); - } - }; + HookService.Instance.PropertyChanged += _hookServiceChangedHandler; // 启动后自动尝试注入 _ = AutoEnsureHook(); } + private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e) + { + if (_disposed) + { + return; + } + + if (e.PropertyName == "Item[]") + { + ApplyLocalizedCategoryNames(); + OnPropertyChanged(nameof(CurrentHookStatus)); + OnPropertyChanged(nameof(HookStatusMessage)); + if (!IsBusy) + { + StatusText = LocalizationService.Instance["Dashboard.Status.Ready"]; + } + } + } + + private void OnHookServicePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_disposed) + { + return; + } + + if (e.PropertyName == nameof(HookService.CurrentStatus)) + { + App.Current.Dispatcher.Invoke(() => + { + OnPropertyChanged(nameof(CurrentHookStatus)); + OnPropertyChanged(nameof(HookStatusMessage)); + ScanSystemCommand.NotifyCanExecuteChanged(); + PickAndScanFileCommand.NotifyCanExecuteChanged(); + RefreshCommand.NotifyCanExecuteChanged(); + }); + } + } + [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 Task.Delay(BenchmarkSemantics.Runtime.HookReconnectStabilizationDelayMs); 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 +340,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(); } } @@ -310,11 +353,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); } @@ -323,17 +366,9 @@ private async Task Refresh() [RelayCommand(CanExecute = nameof(CanExecuteBenchmark))] private async Task ScanSystem() { - _lastScanMode = "System"; - StatusText = LocalizationService.Instance["Dashboard.Status.ScanningSystem"]; - IsBusy = true; - RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Measuring"]; - - App.Current.Dispatcher.Invoke(() => - { - _scanOrderCounter = 0; - Results.Clear(); - DisplayResults.Clear(); - }); + BeginScanSession( + LastScanMode.System, + LocalizationService.Instance["Dashboard.Status.ScanningSystem"]); try { @@ -371,7 +406,7 @@ void TryCompleteUiDrain() }); var mode = UseDeepScan ? ScanMode.Full : ScanMode.Targeted; - await Task.Run(async () => await _benchmarkService.RunSystemBenchmarkAsync(mode, new Progress(progressAction))); + await Task.Run(async () => await _benchmarkService.RunSystemBenchmarkAsync(mode, new Progress(progressAction), _currentScanId)); producerCompleted = true; TryCompleteUiDrain(); await uiDrainTcs.Task; @@ -379,14 +414,11 @@ void TryCompleteUiDrain() // Ensure summary values are refreshed even if no progress callback was emitted. UpdateStats(); - StatusText = string.Format(LocalizationService.Instance["Dashboard.Status.ScanComplete"], Results.Count); - NotificationService.Instance.ShowSuccess(LocalizationService.Instance["Dashboard.Notify.ScanComplete.Title"], string.Format(LocalizationService.Instance["Dashboard.Notify.ScanComplete.Message"], Results.Count)); + NotifyScanComplete(Results.Count); } catch (Exception ex) { - LogService.Instance.Error("Scan System Failed", ex); - StatusText = LocalizationService.Instance["Dashboard.Status.ScanFailed"]; - NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.ScanFailed.Title"], ex.Message); + HandleScanFailure("Scan System Failed", ex, setRealLoadError: false); } finally { @@ -438,36 +470,14 @@ private async Task ScanFile(string filePath) { if (string.IsNullOrEmpty(filePath)) return; - _lastScanMode = "File"; - _lastScanPath = filePath; - StatusText = string.Format(LocalizationService.Instance["Dashboard.Status.ScanningFile"], filePath); - IsBusy = true; - _scanOrderCounter = 0; - Results.Clear(); - DisplayResults.Clear(); // Clear display - RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Measuring"]; + BeginScanSession( + LastScanMode.File, + string.Format(LocalizationService.Instance["Dashboard.Status.ScanningFile"], filePath), + filePath); try { - var results = await Task.Run(() => - { - List? threadResult = null; - var thread = new Thread(() => - { - try - { - threadResult = _benchmarkService.RunBenchmark(filePath); - } - catch (Exception ex) - { - LogService.Instance.Error("Background File Scan Error", ex); - } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - return threadResult ?? new List(); - }); + var results = await RunFileBenchmarkInStaAsync(filePath); if (results.Count > 0) { @@ -477,17 +487,13 @@ private async Task ScanFile(string filePath) InsertSorted(res); } UpdateStats(); - StatusText = string.Format(LocalizationService.Instance["Dashboard.Status.ScanComplete"], results.Count); - NotificationService.Instance.ShowSuccess(LocalizationService.Instance["Dashboard.Notify.ScanComplete.Title"], string.Format(LocalizationService.Instance["Dashboard.Notify.ScanCompleteForFile.Message"], results.Count, System.IO.Path.GetFileName(filePath))); + NotifyScanComplete(results.Count, filePath); } } catch (Exception ex) { - StatusText = LocalizationService.Instance["Dashboard.Status.ScanFailed"]; - LogService.Instance.Error("File Scan Failed", ex); - NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.ScanFailed.Title"], ex.Message); - RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Error"]; + HandleScanFailure("File Scan Failed", ex, setRealLoadError: true); } finally { @@ -495,23 +501,165 @@ private async Task ScanFile(string filePath) } } + private void NotifyScanComplete(int resultCount, string? filePath = null) + { + StatusText = string.Format(LocalizationService.Instance["Dashboard.Status.ScanComplete"], resultCount); + + LogService.Instance.InfoEvent( + "scan.session_completed", + fields: new Dictionary + { + ["scan_id"] = _currentScanId, + ["scan_scope"] = ResolveScanScope(_lastScanMode), + ["scan_depth"] = ResolveScanDepth(_lastScanMode), + ["scan_mode"] = ResolveScanDepth(_lastScanMode), + ["target_path"] = filePath, + ["result_count"] = resultCount, + ["display_result_count"] = DisplayResults.Count, + ["total_load_ms"] = TotalLoadTime, + ["active_extensions"] = ActiveExtensions + }); + + string message = string.IsNullOrEmpty(filePath) + ? string.Format(LocalizationService.Instance["Dashboard.Notify.ScanComplete.Message"], resultCount) + : string.Format( + LocalizationService.Instance["Dashboard.Notify.ScanCompleteForFile.Message"], + resultCount, + System.IO.Path.GetFileName(filePath)); + + NotificationService.Instance.ShowSuccess( + LocalizationService.Instance["Dashboard.Notify.ScanComplete.Title"], + message); + } + + private void HandleScanFailure(string logMessage, Exception ex, bool setRealLoadError) + { + LogService.Instance.ErrorEvent( + "scan.session_failed", + message: logMessage, + ex: ex, + fields: new Dictionary + { + ["scan_id"] = _currentScanId, + ["scan_scope"] = ResolveScanScope(_lastScanMode), + ["scan_depth"] = ResolveScanDepth(_lastScanMode), + ["scan_mode"] = ResolveScanDepth(_lastScanMode), + ["target_path"] = _lastScanPath + }); + StatusText = LocalizationService.Instance["Dashboard.Status.ScanFailed"]; + NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.ScanFailed.Title"], ex.Message); + + if (setRealLoadError) + { + RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Error"]; + } + } + + private void BeginScanSession(LastScanMode mode, string scanningStatusText, string scanPath = "") + { + _lastScanMode = mode; + _lastScanPath = scanPath; + _currentScanId = Guid.NewGuid().ToString("N"); + StatusText = scanningStatusText; + IsBusy = true; + RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Measuring"]; + + LogService.Instance.InfoEvent( + "scan.session_started", + fields: new Dictionary + { + ["scan_id"] = _currentScanId, + ["scan_scope"] = ResolveScanScope(mode), + ["scan_depth"] = ResolveScanDepth(mode), + ["scan_mode"] = ResolveScanDepth(mode), + ["scan_path"] = scanPath, + ["deep_scan"] = UseDeepScan, + ["hook_status"] = HookService.Instance.CurrentStatus + }); + + App.Current.Dispatcher.Invoke(() => + { + _scanOrderCounter = 0; + Results.Clear(); + DisplayResults.Clear(); + }); + } + + private async Task> RunFileBenchmarkInStaAsync(string filePath) + { + return await Task.Run(() => + { + List? threadResult = null; + var thread = new Thread(() => + { + try + { + threadResult = _benchmarkService.RunBenchmark(filePath, _currentScanId); + } + catch (Exception ex) + { + LogService.Instance.Error("Background File Scan Error", ex); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + return threadResult ?? new List(); + }); + } + + private string ResolveScanScope(LastScanMode mode) + { + return mode == LastScanMode.File ? "file" : "system"; + } + + private string ResolveScanDepth(LastScanMode mode) + { + if (mode == LastScanMode.File) + { + return "targeted"; + } + + return UseDeepScan ? "full" : "targeted"; + } + 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 = BenchmarkSemantics.FilterCategory.All, Icon = SymbolRegular.TableMultiple20, IsActive = true }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Files"], Tag = BenchmarkSemantics.Category.File, Icon = SymbolRegular.Document20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Folders"], Tag = BenchmarkSemantics.Category.Folder, Icon = SymbolRegular.Folder20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Background"], Tag = BenchmarkSemantics.Category.Background, Icon = SymbolRegular.Image20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.Drives"], Tag = BenchmarkSemantics.Category.Drive, Icon = SymbolRegular.HardDrive20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.UwpModern"], Tag = BenchmarkSemantics.Category.Uwp, Icon = SymbolRegular.Box20 }, + new CategoryItem { Name = LocalizationService.Instance["Dashboard.Category.StaticVerbs"], Tag = BenchmarkSemantics.Category.Static, Icon = SymbolRegular.PuzzlePiece20 } }; if (SelectedCategoryIndex < 0 || SelectedCategoryIndex >= Categories.Count) { SelectedCategoryIndex = 0; } } + + private static void ApplyRegistryExtensionState(BenchmarkResult item, bool shouldEnable) + { + if (item.RegistryEntries == null || item.RegistryEntries.Count == 0) + { + return; + } + + foreach (var entry in item.RegistryEntries) + { + if (shouldEnable) + { + ExtensionManager.EnableRegistryKey(entry.Path); + } + else + { + ExtensionManager.DisableRegistryKey(entry.Path); + } + } + } [RelayCommand] private void ToggleExtension(BenchmarkResult item) @@ -524,47 +672,27 @@ private void ToggleExtension(BenchmarkResult item) // When this command is executed (e.g. by Click), the property might already be updated or not. // We rely on the Command execution. - bool newState = item.IsEnabled; - - if (!newState) // User turned it OFF (IsEnabled is now false) + bool shouldEnable = item.IsEnabled; + + if (BenchmarkSemantics.IsPackagedExtensionType(item.Type) && item.Clsid.HasValue) { - // Logic to Disable - if ((item.Type == "UWP" || item.Type == "Packaged Extension" || item.Type == "Packaged COM") && item.Clsid.HasValue) - { - ExtensionManager.SetExtensionBlockStatus(item.Clsid.Value, item.Name, true); - } - else if (item.RegistryEntries != null && item.RegistryEntries.Count > 0) - { - foreach (var entry in item.RegistryEntries) - { - ExtensionManager.DisableRegistryKey(entry.Path); - } - } - item.Status = "Disabled (Pending Restart)"; + ExtensionManager.SetExtensionBlockStatus(item.Clsid.Value, item.Name, !shouldEnable); } - else // User turned it ON (IsEnabled is now true) + else { - // Logic to Enable - if ((item.Type == "UWP" || item.Type == "Packaged Extension" || item.Type == "Packaged COM") && item.Clsid.HasValue) - { - ExtensionManager.SetExtensionBlockStatus(item.Clsid.Value, item.Name, false); - } - else if (item.RegistryEntries != null && item.RegistryEntries.Count > 0) - { - foreach (var entry in item.RegistryEntries) - { - ExtensionManager.EnableRegistryKey(entry.Path); - } - } - item.Status = "Enabled (Pending Restart)"; + ApplyRegistryExtensionState(item, shouldEnable); } + + item.Status = shouldEnable + ? BenchmarkSemantics.Status.EnabledPendingRestart + : BenchmarkSemantics.Status.DisabledPendingRestart; UpdateStats(); } 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; } @@ -575,16 +703,18 @@ private void DeleteExtension(BenchmarkResult item) { if (item == null) return; - if (item.Type == "UWP") + if (BenchmarkSemantics.IsPackagedExtensionType(item.Type)) { - 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); @@ -605,12 +735,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); } } @@ -620,18 +752,21 @@ private async Task CopyClsid(BenchmarkResult item) if (item?.Clsid != null) { string clsid = item.Clsid.Value.ToString("B"); - for (int i = 0; i < 5; i++) + for (int i = 0; i < BenchmarkSemantics.Runtime.ClipboardRetryAttempts; i++) { 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) + catch (System.Runtime.InteropServices.COMException ex) + when ((uint)ex.ErrorCode == BenchmarkSemantics.Runtime.ClipboardCantOpenHResult) { // CLIPBRD_E_CANT_OPEN - Wait and retry - await Task.Delay(100); + await Task.Delay(BenchmarkSemantics.Runtime.ClipboardRetryDelayMs); } catch (Exception ex) { @@ -639,7 +774,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"]); } } @@ -650,10 +787,11 @@ private void OpenInRegistry(string path) try { - // Registry path might need translation from HKCR to HKLM/HKCU - string fullPath = path; - if (path.StartsWith("*\\")) fullPath = "HKEY_CLASSES_ROOT\\" + path; - else if (!path.Contains("HKEY_")) fullPath = "HKEY_CLASSES_ROOT\\" + path; + string fullPath = RegistryPathHelper.NormalizeForRegedit(path); + if (string.IsNullOrEmpty(fullPath)) + { + return; + } using (var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Applets\Regedit")) { @@ -669,46 +807,43 @@ 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"]); } } private void UpdateStats() { - // Simple optimization: only recalculate if results actually changed - // and use a single pass where possible - TotalExtensions = Results.Count; - - int disabledCount = 0; - long totalTime = 0; - long activeTime = 0; - long disabledTime = 0; + var stats = BenchmarkStatisticsCalculator.Calculate(Results); + + TotalExtensions = stats.TotalExtensions; + DisabledExtensions = stats.DisabledExtensions; + ActiveExtensions = stats.ActiveExtensions; + TotalLoadTime = stats.TotalLoadTime; + ActiveLoadTime = stats.ActiveLoadTime; + DisabledLoadTime = stats.DisabledLoadTime; + RealLoadTime = stats.RealLoadTimeMs > 0 + ? $"{stats.RealLoadTimeMs} ms" + : LocalizationService.Instance["Dashboard.Value.None"]; + } - foreach (var r in Results) + public void Dispose() + { + if (_disposed) { - totalTime += r.TotalTime; - if (r.IsEnabled) - { - activeTime += r.TotalTime; - } - else - { - disabledCount++; - disabledTime += r.TotalTime; - } + return; } - DisabledExtensions = disabledCount; - ActiveExtensions = TotalExtensions - disabledCount; - TotalLoadTime = totalTime; - ActiveLoadTime = activeTime; - DisabledLoadTime = disabledTime; + _disposed = true; + LocalizationService.Instance.PropertyChanged -= _localizationChangedHandler; + HookService.Instance.PropertyChanged -= _hookServiceChangedHandler; + + _filterCts?.Cancel(); + _filterCts?.Dispose(); + _filterCts = null; - // "Real load" uses wall-clock IPC time when available; fall back to measured COM stage total. - 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"; + GC.SuppressFinalize(this); } } } diff --git a/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml b/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml index 87a41db..1563435 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" @@ -20,6 +21,7 @@ + @@ -47,7 +49,7 @@ - + - + - + - + @@ -378,16 +380,16 @@ + Visibility="{Binding Status, Converter={StaticResource StatusToVisibilityConverter}, ConverterParameter={x:Static converters:StatusVisibilityMode.Fallback}}">