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