From b06aee300aa585a5f5f3cae6b9fde885cb6d0516 Mon Sep 17 00:00:00 2001 From: ZYJ <2897680945@qq.com> Date: Wed, 4 Mar 2026 19:15:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ContextMenuProfiler.Hook/src/injector.cpp | 105 ++++---- .../Core/BenchmarkService.cs | 232 +++++++++++++++--- ContextMenuProfiler.UI/Core/HookIpcClient.cs | 22 +- .../Core/RegistryScanner.cs | 11 +- .../Core/Services/HookService.cs | 32 ++- .../ViewModels/DashboardViewModel.cs | 89 ++++++- .../Views/Pages/DashboardPage.xaml | 33 ++- 7 files changed, 414 insertions(+), 110 deletions(-) diff --git a/ContextMenuProfiler.Hook/src/injector.cpp b/ContextMenuProfiler.Hook/src/injector.cpp index f9f80ce..b0b5414 100644 --- a/ContextMenuProfiler.Hook/src/injector.cpp +++ b/ContextMenuProfiler.Hook/src/injector.cpp @@ -2,6 +2,7 @@ #include #include #include +#include // Enable Debug Privilege (Required for injecting into system processes) bool EnableDebugPrivilege() @@ -35,35 +36,34 @@ bool EnableDebugPrivilege() return true; } -// Helper to inject DLL into process by name -bool InjectDll(const char* processName, const char* dllPath) +static std::vector FindProcessIdsByName(const char* processName) { - DWORD processId = 0; + std::vector processIds; HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (hSnapshot != INVALID_HANDLE_VALUE) + if (hSnapshot == INVALID_HANDLE_VALUE) { - PROCESSENTRY32 pe; - pe.dwSize = sizeof(PROCESSENTRY32); - if (Process32First(hSnapshot, &pe)) - { - do - { - if (_stricmp(pe.szExeFile, processName) == 0) - { - processId = pe.th32ProcessID; - break; - } - } while (Process32Next(hSnapshot, &pe)); - } - CloseHandle(hSnapshot); + return processIds; } - if (processId == 0) + PROCESSENTRY32 pe; + pe.dwSize = sizeof(PROCESSENTRY32); + if (Process32First(hSnapshot, &pe)) { - std::cerr << "Process not found: " << processName << std::endl; - return false; + do + { + if (_stricmp(pe.szExeFile, processName) == 0) + { + processIds.push_back(pe.th32ProcessID); + } + } while (Process32Next(hSnapshot, &pe)); } + CloseHandle(hSnapshot); + return processIds; +} + +static bool InjectDllToProcess(DWORD processId, const char* dllPath) +{ std::cout << "Target Process ID: " << processId << std::endl; HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId); @@ -130,35 +130,30 @@ bool InjectDll(const char* processName, const char* dllPath) return exitCode != 0; } -// Helper to eject DLL from process by name -bool EjectDll(const char* processName, const char* dllName) +// Helper to inject DLL into all matching processes by name +bool InjectDll(const char* processName, const char* dllPath) { - DWORD processId = 0; - HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (hSnapshot != INVALID_HANDLE_VALUE) + std::vector processIds = FindProcessIdsByName(processName); + if (processIds.empty()) { - PROCESSENTRY32 pe; - pe.dwSize = sizeof(PROCESSENTRY32); - if (Process32First(hSnapshot, &pe)) - { - do - { - if (_stricmp(pe.szExeFile, processName) == 0) - { - processId = pe.th32ProcessID; - break; - } - } while (Process32Next(hSnapshot, &pe)); - } - CloseHandle(hSnapshot); + std::cerr << "Process not found: " << processName << std::endl; + return false; } - if (processId == 0) + bool anySuccess = false; + for (DWORD pid : processIds) { - std::cerr << "Process not found: " << processName << std::endl; - return false; + if (InjectDllToProcess(pid, dllPath)) + { + anySuccess = true; + } } + return anySuccess; +} + +static bool EjectDllFromProcess(DWORD processId, const char* dllName) +{ HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId); if (!hProcess) { @@ -211,6 +206,28 @@ bool EjectDll(const char* processName, const char* dllName) return true; } +// Helper to eject DLL from all matching processes by name +bool EjectDll(const char* processName, const char* dllName) +{ + std::vector processIds = FindProcessIdsByName(processName); + if (processIds.empty()) + { + std::cerr << "Process not found: " << processName << std::endl; + return false; + } + + bool anySuccess = false; + for (DWORD pid : processIds) + { + if (EjectDllFromProcess(pid, dllName)) + { + anySuccess = true; + } + } + + return anySuccess; +} + int main(int argc, char* argv[]) { if (argc < 2) @@ -278,4 +295,4 @@ int main(int argc, char* argv[]) } return 0; -} \ No newline at end of file +} diff --git a/ContextMenuProfiler.UI/Core/BenchmarkService.cs b/ContextMenuProfiler.UI/Core/BenchmarkService.cs index 58f53b9..7be6e94 100644 --- a/ContextMenuProfiler.UI/Core/BenchmarkService.cs +++ b/ContextMenuProfiler.UI/Core/BenchmarkService.cs @@ -52,6 +52,39 @@ internal class ClsidMetadata public class BenchmarkService { + private sealed class HookProbeState + { + private int _consecutiveTransportFailures; + private int _skipBudget; + + public void MarkSuccess() + { + Interlocked.Exchange(ref _consecutiveTransportFailures, 0); + Interlocked.Exchange(ref _skipBudget, 0); + } + + public void MarkTransportFailure() + { + int failures = Interlocked.Increment(ref _consecutiveTransportFailures); + // Avoid an all-or-nothing global fallback. Throttle briefly, then probe again. + if (failures >= 6) + { + Interlocked.Exchange(ref _consecutiveTransportFailures, 0); + Interlocked.Exchange(ref _skipBudget, 6); + } + } + + public bool ShouldTemporarilySkipProbe() + { + while (true) + { + int budget = Interlocked.CompareExchange(ref _skipBudget, 0, 0); + if (budget <= 0) return false; + if (Interlocked.CompareExchange(ref _skipBudget, budget - 1, budget) == budget) return true; + } + } + } + public List RunSystemBenchmark(ScanMode mode = ScanMode.Targeted) { // Use Task.Run to avoid deadlocks on UI thread when waiting for async tasks @@ -62,11 +95,46 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = { var allResults = new ConcurrentBag(); var resultsMap = new ConcurrentDictionary(); - var semaphore = new SemaphoreSlim(8); + // Keep parallelism conservative: shell extension probing happens in Explorer + // and aggressive fan-out can cause system stutter. + int maxParallelism = mode == ScanMode.Full ? 2 : 3; + var semaphore = new SemaphoreSlim(maxParallelism); + var hookProbeState = new HookProbeState(); using (var fileContext = ShellTestContext.Create(false)) + using (var folderContext = ShellTestContext.Create(true)) { - // 1. Scan All Registry Handlers (COM) + string driveContextPath = Environment.GetEnvironmentVariable("SystemDrive") ?? "C:"; + if (!driveContextPath.EndsWith("\\", StringComparison.Ordinal)) driveContextPath += "\\"; + if (!Directory.Exists(driveContextPath)) driveContextPath = folderContext.Path; + + // 1. Scan UWP/Packaged extensions first. + // If a legacy COM handler destabilizes hook later, we still keep UWP measurements. + var uwpTasks = PackageScanner.ScanPackagedExtensions(null) + .Where(r => r.Clsid.HasValue) + .Select(async uwpResult => + { + await semaphore.WaitAsync(); + try + { + Guid clsid = uwpResult.Clsid!.Value; + if (!resultsMap.TryAdd(clsid, uwpResult)) return; + + uwpResult.Category = "UWP"; + await EnrichBenchmarkResultAsync(uwpResult, fileContext.Path, folderContext.Path, hookProbeState); + + uwpResult.IsEnabled = !ExtensionManager.IsExtensionBlocked(clsid); + uwpResult.LocationSummary = "Modern Shell (UWP)"; + + allResults.Add(uwpResult); + progress?.Report(uwpResult); + } + finally { semaphore.Release(); } + }); + + await Task.WhenAll(uwpTasks); + + // 2. Scan All Registry Handlers (COM) var registryHandlers = RegistryScanner.ScanHandlers(mode); var comTasks = registryHandlers.Select(async clsidEntry => { @@ -75,7 +143,7 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = { var clsid = clsidEntry.Key; var handlerInfos = clsidEntry.Value; - + if (resultsMap.ContainsKey(clsid)) return; var meta = QueryClsidMetadata(clsid); var result = new BenchmarkResult @@ -90,11 +158,13 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = }; if (string.IsNullOrEmpty(result.Name)) result.Name = $"Unknown ({clsid})"; - - resultsMap[clsid] = result; + if (!resultsMap.TryAdd(clsid, result)) return; + result.Category = DetermineCategory(result.RegistryEntries.Select(e => e.Location)); + string primaryContextPath = PickPrimaryContext(result, fileContext.Path, folderContext.Path, driveContextPath); + string secondaryContextPath = PickSecondaryContext(result, fileContext.Path, folderContext.Path, driveContextPath); - await EnrichBenchmarkResultAsync(result, fileContext.Path); + await EnrichBenchmarkResultAsync(result, primaryContextPath, secondaryContextPath, hookProbeState); bool isBlocked = ExtensionManager.IsExtensionBlocked(clsid); bool hasDisabledPath = result.RegistryEntries.Any(e => e.Location.Contains("[Disabled]")); @@ -109,7 +179,7 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = await Task.WhenAll(comTasks); - // 2. Scan Static Verbs + // 3. Scan Static Verbs var staticVerbs = RegistryScanner.ScanStaticVerbs(); foreach (var verbEntry in staticVerbs) { @@ -124,9 +194,10 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = Type = "Static", Status = "OK", BinaryPath = ExtractExecutablePath(command), - RegistryEntries = paths.Select(p => new RegistryHandlerInfo { - Path = p, - Location = $"Registry (Shell) - {p.Split('\\')[0]}" + RegistryEntries = paths.Select(p => new RegistryHandlerInfo + { + Path = p, + Location = $"Registry (Shell) - {p.Split('\\')[0]}" }).ToList(), InterfaceType = "Static Verb", TotalTime = 0, @@ -142,36 +213,98 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = progress?.Report(verbResult); } - // 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(); } - }); + return allResults.ToList(); + } + } - await Task.WhenAll(uwpTasks); + private static bool IsBetterHookData(HookResponse? candidate, HookResponse? baseline) + { + if (candidate == null) return false; + if (baseline == null) return true; - return allResults.ToList(); + if (candidate.success && !baseline.success) return true; + + if (candidate.success && baseline.success) + { + bool candidateHasNames = !string.IsNullOrEmpty(candidate.names); + bool baselineHasNames = !string.IsNullOrEmpty(baseline.names); + if (candidateHasNames && !baselineHasNames) return true; + } + + return false; + } + + private static bool IsMscoreeShim(string? binaryPath) + { + if (string.IsNullOrWhiteSpace(binaryPath)) return false; + return binaryPath.Equals("mscoree.dll", StringComparison.OrdinalIgnoreCase) || + binaryPath.EndsWith("\\mscoree.dll", StringComparison.OrdinalIgnoreCase) || + binaryPath.EndsWith("/mscoree.dll", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeWellKnownBinaryPath(string? binaryPath) + { + if (string.IsNullOrWhiteSpace(binaryPath)) return ""; + if (Path.IsPathRooted(binaryPath)) return binaryPath; + + // mscoree is a system shim dll; registry commonly stores only file name. + if (binaryPath.Equals("mscoree.dll", StringComparison.OrdinalIgnoreCase)) + { + var systemPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "mscoree.dll"); + if (File.Exists(systemPath)) return systemPath; } + + return binaryPath; + } + + private static string PickPrimaryContext(BenchmarkResult result, string fileContextPath, string folderContextPath, string driveContextPath) + { + return result.Category switch + { + "Folder" => folderContextPath, + "Background" => folderContextPath, + "Drive" => driveContextPath, + _ => fileContextPath + }; } - private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string contextPath) + private static string PickSecondaryContext(BenchmarkResult result, string fileContextPath, string folderContextPath, string driveContextPath) + { + return result.Category switch + { + "Folder" => fileContextPath, + "Background" => fileContextPath, + "Drive" => folderContextPath, + _ => folderContextPath + }; + } + + private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string contextPath, string? secondaryContextPath, HookProbeState hookProbeState) { if (!result.Clsid.HasValue) return; + if (hookProbeState.ShouldTemporarilySkipProbe()) + { + if (result.Status == "Unknown" || result.Status == "OK") + { + result.Status = "Registry Fallback"; + result.DetailedStatus = "Hook service is temporarily throttled after repeated timeouts. This item was measured in fallback mode."; + } + return; + } + + // Normalize known shim paths first so existence checks are accurate. + result.BinaryPath = NormalizeWellKnownBinaryPath(result.BinaryPath); + + // Managed COM shim handlers (mscoree) are a known source of explorer stalls in probe mode. + // Skip active hook probing for stability. + if (result.Type == "COM" && IsMscoreeShim(result.BinaryPath)) + { + result.Status = "Registry Fallback"; + result.DetailedStatus = "Skipped active timing for managed COM shim (mscoree.dll) to avoid Explorer freezes. Displayed metadata is from registry."; + return; + } + // Check for Orphaned / Missing DLL if (!string.IsNullOrEmpty(result.BinaryPath) && !File.Exists(result.BinaryPath)) { @@ -181,8 +314,31 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con var hookData = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), contextPath, result.BinaryPath); + // Retry once on transport failure (server can be briefly busy because pipe handling is serialized). + if (hookData == null) + { + await Task.Delay(120); + hookData = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), contextPath, result.BinaryPath); + } + + // Context-sensitive retry: many handlers are file/folder/drive specific. + bool shouldTrySecondaryContext = + !string.IsNullOrWhiteSpace(secondaryContextPath) && + !string.Equals(contextPath, secondaryContextPath, StringComparison.OrdinalIgnoreCase) && + (hookData == null || (hookData.success && string.IsNullOrEmpty(hookData.names))); + + if (shouldTrySecondaryContext) + { + var secondProbe = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), secondaryContextPath!, result.BinaryPath); + if (hookData == null || IsBetterHookData(secondProbe, hookData)) + { + hookData = secondProbe; + } + } + if (hookData != null && hookData.success) { + hookProbeState.MarkSuccess(); result.InterfaceType = hookData.@interface; if (!string.IsNullOrEmpty(hookData.names)) { @@ -213,10 +369,16 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con result.Status = "Load Error"; result.DetailedStatus = $"The Hook service failed to load this extension. Error: {hookData.error ?? "Unknown Error"}"; } - else if (hookData == null && result.Status == "Unknown") + else if (hookData == null) { - 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."; + hookProbeState.MarkTransportFailure(); + // Preserve explicit hard-failure statuses, otherwise surface fallback clearly. + if (!string.Equals(result.Status, "Load Error", StringComparison.OrdinalIgnoreCase) && + !string.Equals(result.Status, "Orphaned / Missing DLL", StringComparison.OrdinalIgnoreCase)) + { + result.Status = "Registry Fallback"; + result.DetailedStatus = "The Hook service could not be reached or timed out. Timing data is unavailable for this item."; + } } } diff --git a/ContextMenuProfiler.UI/Core/HookIpcClient.cs b/ContextMenuProfiler.UI/Core/HookIpcClient.cs index 1ba877f..f5385c1 100644 --- a/ContextMenuProfiler.UI/Core/HookIpcClient.cs +++ b/ContextMenuProfiler.UI/Core/HookIpcClient.cs @@ -28,6 +28,8 @@ public class HookResponse public static class HookIpcClient { private const string PipeName = "ContextMenuProfilerHook"; + private const int ConnectTimeoutMs = 500; + private const int RequestTimeoutMs = 2500; private static readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); public static async Task GetHookDataAsync(string clsid, string? contextPath = null, string? dllHint = null) @@ -39,14 +41,18 @@ public static class HookIpcClient try { File.WriteAllText(path, "probe"); } catch {} } - await _lock.WaitAsync(); + bool lockTaken = false; try { + await _lock.WaitAsync(); + lockTaken = true; + + using var timeoutCts = new CancellationTokenSource(RequestTimeoutMs); using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) { try { - await client.ConnectAsync(500); + await client.ConnectAsync(ConnectTimeoutMs, timeoutCts.Token); } catch (Exception ex) { @@ -57,10 +63,11 @@ public static class HookIpcClient // 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); + await client.WriteAsync(request, 0, request.Length, timeoutCts.Token); + await client.FlushAsync(timeoutCts.Token); byte[] responseBuf = new byte[65536]; - int read = await client.ReadAsync(responseBuf, 0, responseBuf.Length); + int read = await client.ReadAsync(responseBuf, 0, responseBuf.Length, timeoutCts.Token); if (read <= 0) return null; @@ -83,6 +90,11 @@ public static class HookIpcClient } } } + catch (OperationCanceledException) + { + Debug.WriteLine("[IPC DIAG] Request timed out."); + return null; + } catch (Exception ex) { Debug.WriteLine($"[IPC DIAG] Critical Error: {ex.Message}"); @@ -90,7 +102,7 @@ public static class HookIpcClient } finally { - _lock.Release(); + if (lockTaken) _lock.Release(); } } diff --git a/ContextMenuProfiler.UI/Core/RegistryScanner.cs b/ContextMenuProfiler.UI/Core/RegistryScanner.cs index 78f089f..9de0049 100644 --- a/ContextMenuProfiler.UI/Core/RegistryScanner.cs +++ b/ContextMenuProfiler.UI/Core/RegistryScanner.cs @@ -54,8 +54,13 @@ public static Dictionary> ScanHandlers(ScanMode { string[] rootKeys = Registry.ClassesRoot.GetSubKeyNames(); - // Use Parallel.ForEach for faster scanning - Parallel.ForEach(rootKeys, keyName => + // Bound parallelism to avoid saturating CPU during deep scans. + var options = new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) + }; + + Parallel.ForEach(rootKeys, options, keyName => { if (keyName.StartsWith(".")) { @@ -246,4 +251,4 @@ private static void ScanShellKey(ConcurrentDictionary> verb } } } -} \ No newline at end of file +} diff --git a/ContextMenuProfiler.UI/Core/Services/HookService.cs b/ContextMenuProfiler.UI/Core/Services/HookService.cs index 61e7fb2..29ae68b 100644 --- a/ContextMenuProfiler.UI/Core/Services/HookService.cs +++ b/ContextMenuProfiler.UI/Core/Services/HookService.cs @@ -93,27 +93,35 @@ public async Task GetStatusAsync() return status; } - private Process? _cachedExplorer; - private bool IsDllInjected() { try { - // Cache explorer process to avoid repeated lookups - if (_cachedExplorer == null || _cachedExplorer.HasExited) + // Multiple explorer.exe instances may exist. + // Treat injected as true if any explorer process has the module loaded. + foreach (var proc in Process.GetProcessesByName("explorer")) { - _cachedExplorer = Process.GetProcessesByName("explorer").FirstOrDefault(); + try + { + if (proc.HasExited) continue; + if (proc.Modules.Cast().Any(m => m.ModuleName.Equals(DllName, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + catch + { + // Ignore per-process access/read failures and continue checking others. + } + finally + { + proc.Dispose(); + } } - - if (_cachedExplorer == null) return false; - - // Process.Modules is expensive. We only call this when pipe check fails. - _cachedExplorer.Refresh(); // Ensure we have latest module list - return _cachedExplorer.Modules.Cast().Any(m => m.ModuleName.Equals(DllName, StringComparison.OrdinalIgnoreCase)); + return false; } catch { - _cachedExplorer = null; return false; } } diff --git a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs index 1cecd57..6d22f6a 100644 --- a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs @@ -129,12 +129,24 @@ private bool MatchesFilter(BenchmarkResult result) bool categoryMatch = SelectedCategory == "All" || result.Category == SelectedCategory; if (!categoryMatch) return false; + // Optional invalid-result filter (N/A, fallback, static) + if (HideInvalidResults && IsInvalidTimingResult(result)) return false; + // Search Match if (string.IsNullOrWhiteSpace(SearchText)) return true; return result.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || (result.Path != null && result.Path.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); } + private static bool IsInvalidTimingResult(BenchmarkResult result) + { + if (result.Type == "Static") return true; + if (string.Equals(result.Status, "Registry Fallback", StringComparison.OrdinalIgnoreCase)) return true; + if (string.Equals(result.Status, "Load Error", StringComparison.OrdinalIgnoreCase)) return true; + if (string.Equals(result.Status, "Orphaned / Missing DLL", StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + [ObservableProperty] private int _selectedSortIndex = 0; // 0: Time Desc, 1: Time Asc, 2: Name @@ -143,6 +155,14 @@ partial void OnSelectedSortIndexChanged(int value) _ = ApplyFilterAsync(); } + [ObservableProperty] + private bool _hideInvalidResults = false; + + partial void OnHideInvalidResultsChanged(bool value) + { + _ = ApplyFilterAsync(); + } + [ObservableProperty] private string _statusText = "Ready to scan"; @@ -308,27 +328,25 @@ private async Task ScanSystem() _lastScanMode = "System"; StatusText = "Scanning system..."; IsBusy = true; + bool startedWithHook = HookService.Instance.CurrentStatus != HookStatus.Disconnected; App.Current.Dispatcher.Invoke(() => { Results.Clear(); DisplayResults.Clear(); + ResetStats(); }); try { - var progressAction = new Action(async result => + var progressAction = new Action(result => { - // Use Background priority to ensure UI remains smooth during scan - await App.Current.Dispatcher.InvokeAsync(() => - { - InsertSorted(result); - UpdateStats(); - }, System.Windows.Threading.DispatcherPriority.Background); + InsertSorted(result); + AccumulateStats(result); }); var mode = UseDeepScan ? ScanMode.Full : ScanMode.Targeted; - await Task.Run(async () => await _benchmarkService.RunSystemBenchmarkAsync(mode, new Progress(progressAction))); + await _benchmarkService.RunSystemBenchmarkAsync(mode, new Progress(progressAction)); StatusText = $"Scan complete. Found {Results.Count} extensions."; NotificationService.Instance.ShowSuccess("Scan Complete", $"Found {Results.Count} extensions."); @@ -342,6 +360,34 @@ await App.Current.Dispatcher.InvokeAsync(() => finally { IsBusy = false; + + // If hook was available at scan start but got lost during scan, + // try one quick recovery so subsequent scans don't immediately fall back. + if (startedWithHook) + { + try + { + var endStatus = await HookService.Instance.GetStatusAsync(); + if (endStatus == HookStatus.Disconnected) + { + bool recovered = await HookService.Instance.InjectAsync(); + if (recovered) + { + await Task.Delay(300); + endStatus = await HookService.Instance.GetStatusAsync(); + } + + if (endStatus != HookStatus.Active) + { + NotificationService.Instance.ShowWarning("Hook Disconnected", "Hook service disconnected during scan. Results may contain fallback data."); + } + } + } + catch + { + // No-op: scan result is already available. + } + } } } @@ -357,6 +403,33 @@ private void InsertSorted(BenchmarkResult newItem) } } + private void ResetStats() + { + TotalExtensions = 0; + DisabledExtensions = 0; + ActiveExtensions = 0; + TotalLoadTime = 0; + ActiveLoadTime = 0; + DisabledLoadTime = 0; + } + + private void AccumulateStats(BenchmarkResult item) + { + TotalExtensions += 1; + TotalLoadTime += item.TotalTime; + + if (item.IsEnabled) + { + ActiveExtensions += 1; + ActiveLoadTime += item.TotalTime; + } + else + { + DisabledExtensions += 1; + DisabledLoadTime += item.TotalTime; + } + } + private Comparison CurrentComparer => SelectedSortIndex switch { 0 => (a, b) => b.TotalTime.CompareTo(a.TotalTime), // Time Desc diff --git a/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml b/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml index 1ffa27a..5044171 100644 --- a/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml +++ b/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml @@ -189,12 +189,19 @@ + - + + + @@ -430,8 +437,28 @@ - + + + + + From 9249577fe4995b713cae1aac6a1c98b4cb11bd79 Mon Sep 17 00:00:00 2001 From: ZYJ <2897680945@qq.com> Date: Wed, 4 Mar 2026 20:52:10 +0800 Subject: [PATCH 2/2] fix(ui): restore i18n/language switch from main and fix dashboard toolbar overlap --- ContextMenuProfiler.UI/App.xaml.cs | 1 + .../Converters/LoadTimeToTextConverter.cs | 47 +++ .../NullOrEmptyToLocalizedConverter.cs | 32 ++ .../Core/BenchmarkService.cs | 300 +++++++----------- ContextMenuProfiler.UI/Core/HookIpcClient.cs | 200 ++++++++---- .../Core/Services/HookService.cs | 71 ++++- .../Core/Services/LocalizationService.cs | 296 +++++++++++++++++ .../Core/Services/UserPreferencesService.cs | 50 +++ ContextMenuProfiler.UI/MainWindow.xaml | 2 +- .../ViewModels/DashboardViewModel.cs | 215 +++++++------ .../ViewModels/MainWindowViewModel.cs | 97 ++++-- .../ViewModels/SettingsViewModel.cs | 31 +- .../Views/Pages/DashboardPage.xaml | 139 +++++--- .../Views/Pages/SettingsPage.xaml | 40 ++- 14 files changed, 1073 insertions(+), 448 deletions(-) create mode 100644 ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs create mode 100644 ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs create mode 100644 ContextMenuProfiler.UI/Core/Services/LocalizationService.cs create mode 100644 ContextMenuProfiler.UI/Core/Services/UserPreferencesService.cs diff --git a/ContextMenuProfiler.UI/App.xaml.cs b/ContextMenuProfiler.UI/App.xaml.cs index 08233be..8a7f2d7 100644 --- a/ContextMenuProfiler.UI/App.xaml.cs +++ b/ContextMenuProfiler.UI/App.xaml.cs @@ -21,6 +21,7 @@ public App() protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + LocalizationService.Instance.InitializeFromPreferences(); LogService.Instance.Info("Application Started"); CleanTempFiles(); CleanHookOutputFiles(); diff --git a/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs new file mode 100644 index 0000000..d533dd9 --- /dev/null +++ b/ContextMenuProfiler.UI/Converters/LoadTimeToTextConverter.cs @@ -0,0 +1,47 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace ContextMenuProfiler.UI.Converters +{ + public class LoadTimeToTextConverter : IMultiValueConverter + { + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length < 2) return "N/A"; + + 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)) + { + return "N/A"; + } + + return $"{ms} ms"; + } + + private static bool ShouldShowNa(string status, long ms) + { + if (ms > 0) return false; + + 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) || + status.Contains("Not Measured", StringComparison.OrdinalIgnoreCase) || + status.Contains("Unsupported", StringComparison.OrdinalIgnoreCase) || + status.Contains("No Menu", StringComparison.OrdinalIgnoreCase); + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs b/ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs new file mode 100644 index 0000000..1601879 --- /dev/null +++ b/ContextMenuProfiler.UI/Converters/NullOrEmptyToLocalizedConverter.cs @@ -0,0 +1,32 @@ +using ContextMenuProfiler.UI.Core.Services; +using System; +using System.Globalization; +using System.Windows.Data; + +namespace ContextMenuProfiler.UI.Converters +{ + public class NullOrEmptyToLocalizedConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + string text = value?.ToString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + + string key = parameter?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(key)) + { + return string.Empty; + } + + return LocalizationService.Instance[key]; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/ContextMenuProfiler.UI/Core/BenchmarkService.cs b/ContextMenuProfiler.UI/Core/BenchmarkService.cs index 7be6e94..a0f5c0a 100644 --- a/ContextMenuProfiler.UI/Core/BenchmarkService.cs +++ b/ContextMenuProfiler.UI/Core/BenchmarkService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -30,6 +29,11 @@ public class BenchmarkResult public long InitTime { get; set; } public long CreateTime { get; set; } public long QueryTime { get; set; } + public long WallClockTime { get; set; } + public long LockWaitTime { get; set; } + public long ConnectTime { get; set; } + public long IpcRoundTripTime { get; set; } + public long ScanOrder { get; set; } // Extended Info public string? PackageName { get; set; } @@ -52,38 +56,13 @@ internal class ClsidMetadata public class BenchmarkService { - private sealed class HookProbeState + private static readonly string[] KnownUnstableHandlerTokens = { - private int _consecutiveTransportFailures; - private int _skipBudget; - - public void MarkSuccess() - { - Interlocked.Exchange(ref _consecutiveTransportFailures, 0); - Interlocked.Exchange(ref _skipBudget, 0); - } - - public void MarkTransportFailure() - { - int failures = Interlocked.Increment(ref _consecutiveTransportFailures); - // Avoid an all-or-nothing global fallback. Throttle briefly, then probe again. - if (failures >= 6) - { - Interlocked.Exchange(ref _consecutiveTransportFailures, 0); - Interlocked.Exchange(ref _skipBudget, 6); - } - } - - public bool ShouldTemporarilySkipProbe() - { - while (true) - { - int budget = Interlocked.CompareExchange(ref _skipBudget, 0, 0); - if (budget <= 0) return false; - if (Interlocked.CompareExchange(ref _skipBudget, budget - 1, budget) == budget) return true; - } - } - } + "PintoStartScreen", + "NvcplDesktopContext", + "NvAppDesktopContext", + "NVIDIA CPL Context Menu Extension" + }; public List RunSystemBenchmark(ScanMode mode = ScanMode.Targeted) { @@ -95,46 +74,11 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = { var allResults = new ConcurrentBag(); var resultsMap = new ConcurrentDictionary(); - // Keep parallelism conservative: shell extension probing happens in Explorer - // and aggressive fan-out can cause system stutter. - int maxParallelism = mode == ScanMode.Full ? 2 : 3; - var semaphore = new SemaphoreSlim(maxParallelism); - var hookProbeState = new HookProbeState(); + var semaphore = new SemaphoreSlim(8); using (var fileContext = ShellTestContext.Create(false)) - using (var folderContext = ShellTestContext.Create(true)) { - string driveContextPath = Environment.GetEnvironmentVariable("SystemDrive") ?? "C:"; - if (!driveContextPath.EndsWith("\\", StringComparison.Ordinal)) driveContextPath += "\\"; - if (!Directory.Exists(driveContextPath)) driveContextPath = folderContext.Path; - - // 1. Scan UWP/Packaged extensions first. - // If a legacy COM handler destabilizes hook later, we still keep UWP measurements. - var uwpTasks = PackageScanner.ScanPackagedExtensions(null) - .Where(r => r.Clsid.HasValue) - .Select(async uwpResult => - { - await semaphore.WaitAsync(); - try - { - Guid clsid = uwpResult.Clsid!.Value; - if (!resultsMap.TryAdd(clsid, uwpResult)) return; - - uwpResult.Category = "UWP"; - await EnrichBenchmarkResultAsync(uwpResult, fileContext.Path, folderContext.Path, hookProbeState); - - uwpResult.IsEnabled = !ExtensionManager.IsExtensionBlocked(clsid); - uwpResult.LocationSummary = "Modern Shell (UWP)"; - - allResults.Add(uwpResult); - progress?.Report(uwpResult); - } - finally { semaphore.Release(); } - }); - - await Task.WhenAll(uwpTasks); - - // 2. Scan All Registry Handlers (COM) + // 1. Scan All Registry Handlers (COM) var registryHandlers = RegistryScanner.ScanHandlers(mode); var comTasks = registryHandlers.Select(async clsidEntry => { @@ -143,7 +87,7 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = { var clsid = clsidEntry.Key; var handlerInfos = clsidEntry.Value; - + if (resultsMap.ContainsKey(clsid)) return; var meta = QueryClsidMetadata(clsid); var result = new BenchmarkResult @@ -158,13 +102,11 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = }; if (string.IsNullOrEmpty(result.Name)) result.Name = $"Unknown ({clsid})"; - if (!resultsMap.TryAdd(clsid, result)) return; - + + resultsMap[clsid] = result; result.Category = DetermineCategory(result.RegistryEntries.Select(e => e.Location)); - string primaryContextPath = PickPrimaryContext(result, fileContext.Path, folderContext.Path, driveContextPath); - string secondaryContextPath = PickSecondaryContext(result, fileContext.Path, folderContext.Path, driveContextPath); - await EnrichBenchmarkResultAsync(result, primaryContextPath, secondaryContextPath, hookProbeState); + await EnrichBenchmarkResultAsync(result, fileContext.Path); bool isBlocked = ExtensionManager.IsExtensionBlocked(clsid); bool hasDisabledPath = result.RegistryEntries.Any(e => e.Location.Contains("[Disabled]")); @@ -179,7 +121,7 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = await Task.WhenAll(comTasks); - // 3. Scan Static Verbs + // 2. Scan Static Verbs var staticVerbs = RegistryScanner.ScanStaticVerbs(); foreach (var verbEntry in staticVerbs) { @@ -192,14 +134,14 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = { Name = name, Type = "Static", - Status = "OK", + Status = "Static (Not Measured)", BinaryPath = ExtractExecutablePath(command), - RegistryEntries = paths.Select(p => new RegistryHandlerInfo - { - Path = p, - Location = $"Registry (Shell) - {p.Split('\\')[0]}" + 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" }; @@ -213,95 +155,45 @@ public async Task> RunSystemBenchmarkAsync(ScanMode mode = progress?.Report(verbResult); } - return allResults.ToList(); - } - } - - private static bool IsBetterHookData(HookResponse? candidate, HookResponse? baseline) - { - if (candidate == null) return false; - if (baseline == null) return true; - - if (candidate.success && !baseline.success) return true; - - if (candidate.success && baseline.success) - { - bool candidateHasNames = !string.IsNullOrEmpty(candidate.names); - bool baselineHasNames = !string.IsNullOrEmpty(baseline.names); - if (candidateHasNames && !baselineHasNames) return true; - } - - return false; - } - - private static bool IsMscoreeShim(string? binaryPath) - { - if (string.IsNullOrWhiteSpace(binaryPath)) return false; - return binaryPath.Equals("mscoree.dll", StringComparison.OrdinalIgnoreCase) || - binaryPath.EndsWith("\\mscoree.dll", StringComparison.OrdinalIgnoreCase) || - binaryPath.EndsWith("/mscoree.dll", StringComparison.OrdinalIgnoreCase); - } + // 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(); } + }); - private static string NormalizeWellKnownBinaryPath(string? binaryPath) - { - if (string.IsNullOrWhiteSpace(binaryPath)) return ""; - if (Path.IsPathRooted(binaryPath)) return binaryPath; + await Task.WhenAll(uwpTasks); - // mscoree is a system shim dll; registry commonly stores only file name. - if (binaryPath.Equals("mscoree.dll", StringComparison.OrdinalIgnoreCase)) - { - var systemPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "mscoree.dll"); - if (File.Exists(systemPath)) return systemPath; + return allResults.ToList(); } - - return binaryPath; } - private static string PickPrimaryContext(BenchmarkResult result, string fileContextPath, string folderContextPath, string driveContextPath) - { - return result.Category switch - { - "Folder" => folderContextPath, - "Background" => folderContextPath, - "Drive" => driveContextPath, - _ => fileContextPath - }; - } - - private static string PickSecondaryContext(BenchmarkResult result, string fileContextPath, string folderContextPath, string driveContextPath) - { - return result.Category switch - { - "Folder" => fileContextPath, - "Background" => fileContextPath, - "Drive" => folderContextPath, - _ => folderContextPath - }; - } - - private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string contextPath, string? secondaryContextPath, HookProbeState hookProbeState) + private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string contextPath) { if (!result.Clsid.HasValue) return; - if (hookProbeState.ShouldTemporarilySkipProbe()) - { - if (result.Status == "Unknown" || result.Status == "OK") - { - result.Status = "Registry Fallback"; - result.DetailedStatus = "Hook service is temporarily throttled after repeated timeouts. This item was measured in fallback mode."; - } - return; - } - - // Normalize known shim paths first so existence checks are accurate. - result.BinaryPath = NormalizeWellKnownBinaryPath(result.BinaryPath); - - // Managed COM shim handlers (mscoree) are a known source of explorer stalls in probe mode. - // Skip active hook probing for stability. - if (result.Type == "COM" && IsMscoreeShim(result.BinaryPath)) + if (IsKnownUnstableHandler(result)) { - result.Status = "Registry Fallback"; - result.DetailedStatus = "Skipped active timing for managed COM shim (mscoree.dll) to avoid Explorer freezes. Displayed metadata is from registry."; + 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; return; } @@ -312,40 +204,26 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con result.DetailedStatus = $"The file '{result.BinaryPath}' was not found on disk. This extension is likely corrupted or uninstalled."; } - var hookData = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), contextPath, result.BinaryPath); - - // Retry once on transport failure (server can be briefly busy because pipe handling is serialized). - if (hookData == null) - { - await Task.Delay(120); - hookData = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), contextPath, result.BinaryPath); - } - - // Context-sensitive retry: many handlers are file/folder/drive specific. - bool shouldTrySecondaryContext = - !string.IsNullOrWhiteSpace(secondaryContextPath) && - !string.Equals(contextPath, secondaryContextPath, StringComparison.OrdinalIgnoreCase) && - (hookData == null || (hookData.success && string.IsNullOrEmpty(hookData.names))); - - if (shouldTrySecondaryContext) - { - var secondProbe = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), secondaryContextPath!, result.BinaryPath); - if (hookData == null || IsBetterHookData(secondProbe, hookData)) - { - hookData = secondProbe; - } - } + var hookCall = await HookIpcClient.GetHookDataAsync(result.Clsid.Value.ToString("B"), contextPath, result.BinaryPath); + 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) { - hookProbeState.MarkSuccess(); result.InterfaceType = hookData.@interface; if (!string.IsNullOrEmpty(hookData.names)) { - result.Name = hookData.names.Replace("|", ", "); + // 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") + 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."; @@ -371,17 +249,53 @@ private async Task EnrichBenchmarkResultAsync(BenchmarkResult result, string con } else if (hookData == null) { - hookProbeState.MarkTransportFailure(); - // Preserve explicit hard-failure statuses, otherwise surface fallback clearly. - if (!string.Equals(result.Status, "Load Error", StringComparison.OrdinalIgnoreCase) && - !string.Equals(result.Status, "Orphaned / Missing DLL", StringComparison.OrdinalIgnoreCase)) + if (result.Status != "Load Error" && result.Status != "Orphaned / Missing DLL") { - result.Status = "Registry Fallback"; - result.DetailedStatus = "The Hook service could not be reached or timed out. Timing data is unavailable for this item."; + if (string.Equals(result.Type, "UWP", StringComparison.OrdinalIgnoreCase)) + { + result.Status = "Unsupported (UWP)"; + result.DetailedStatus = "This UWP/packaged extension could not be benchmarked via current Hook path on this system."; + } + 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."; + } } } } + private static bool IsKnownUnstableHandler(BenchmarkResult result) + { + if (!string.IsNullOrWhiteSpace(result.Name) && + KnownUnstableHandlerTokens.Any(token => result.Name.Contains(token, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(result.FriendlyName) && + KnownUnstableHandlerTokens.Any(token => result.FriendlyName.Contains(token, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + if (result.RegistryEntries != null) + { + 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)))) + { + return true; + } + } + } + + return false; + } + private ClsidMetadata QueryClsidMetadata(Guid clsid, int depth = 0) { var meta = new ClsidMetadata(); diff --git a/ContextMenuProfiler.UI/Core/HookIpcClient.cs b/ContextMenuProfiler.UI/Core/HookIpcClient.cs index f5385c1..199e71c 100644 --- a/ContextMenuProfiler.UI/Core/HookIpcClient.cs +++ b/ContextMenuProfiler.UI/Core/HookIpcClient.cs @@ -25,91 +25,181 @@ public class HookResponse public int state { get; set; } } + public class HookCallResult + { + public HookResponse? data { get; set; } + public long lock_wait_ms { get; set; } + public long connect_ms { get; set; } + public long roundtrip_ms { get; set; } + public long total_ms { get; set; } + } + public static class HookIpcClient { private const string PipeName = "ContextMenuProfilerHook"; + private const int LockAcquireTimeoutMs = 5000; private const int ConnectTimeoutMs = 500; - private const int RequestTimeoutMs = 2500; - private static readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + private const int RoundTripTimeoutMs = 3500; + internal static readonly SemaphoreSlim IpcLock = new SemaphoreSlim(1, 1); - 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) { - // Default bait path if none provided - string path = contextPath ?? Path.Combine(Path.GetTempPath(), "ContextMenuProfiler_probe.txt"); - if (!File.Exists(path) && !Directory.Exists(path)) - { - try { File.WriteAllText(path, "probe"); } catch {} - } - - bool lockTaken = false; + var result = new HookCallResult(); + var swTotal = Stopwatch.StartNew(); try { - await _lock.WaitAsync(); - lockTaken = true; + // Default bait path if none provided + string path = contextPath ?? Path.Combine(Path.GetTempPath(), "ContextMenuProfiler_probe.txt"); + if (!File.Exists(path) && !Directory.Exists(path)) + { + try { File.WriteAllText(path, "probe"); } catch {} + } - using var timeoutCts = new CancellationTokenSource(RequestTimeoutMs); - using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) + for (int attempt = 0; attempt < 2; attempt++) { - try - { - await client.ConnectAsync(ConnectTimeoutMs, timeoutCts.Token); - } - catch (Exception ex) + bool hasLock = false; + try { - Debug.WriteLine($"[IPC DIAG] Connection Failed: {ex.Message}"); - return null; - } + var swLock = Stopwatch.StartNew(); + hasLock = await IpcLock.WaitAsync(LockAcquireTimeoutMs); + swLock.Stop(); + result.lock_wait_ms += Math.Max(0, (long)swLock.Elapsed.TotalMilliseconds); + if (!hasLock) + { + if (attempt == 0) + { + await Task.Delay(150); + continue; + } + return result; + } + + using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) + { + 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(120); + continue; + } + return result; + } + swConnect.Stop(); + result.connect_ms += Math.Max(0, (long)swConnect.Elapsed.TotalMilliseconds); - // 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, timeoutCts.Token); - await client.FlushAsync(timeoutCts.Token); + var swRoundTrip = Stopwatch.StartNew(); + // 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); - byte[] responseBuf = new byte[65536]; - int read = await client.ReadAsync(responseBuf, 0, responseBuf.Length, timeoutCts.Token); - - if (read <= 0) return null; + byte[] responseBuf = new byte[65536]; + int read; + try + { + read = await client.ReadAsync(responseBuf, 0, responseBuf.Length).WaitAsync(TimeSpan.FromMilliseconds(RoundTripTimeoutMs)); + } + catch (TimeoutException) + { + swRoundTrip.Stop(); + result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); + if (attempt == 0) + { + await Task.Delay(120); + continue; + } + return result; + } + + if (read <= 0) + { + swRoundTrip.Stop(); + result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); + if (attempt == 0) + { + await Task.Delay(120); + continue; + } + return result; + } - string response = Encoding.UTF8.GetString(responseBuf, 0, read).TrimEnd('\0'); - try + 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(120); + continue; + } + return result; + } + catch (JsonException) + { + swRoundTrip.Stop(); + result.roundtrip_ms += Math.Max(0, (long)swRoundTrip.Elapsed.TotalMilliseconds); + if (attempt == 0) + { + await Task.Delay(120); + continue; + } + return result; + } + } + } + catch (Exception ex) { - // 寻找第一个 { 和最后一个 } 确保 JSON 完整 - int start = response.IndexOf('{'); - int end = response.LastIndexOf('}'); - if (start != -1 && end != -1 && end > start) + Debug.WriteLine($"[IPC DIAG] Critical Error: {ex.Message}"); + if (attempt == 0) { - string json = response.Substring(start, end - start + 1); - return JsonSerializer.Deserialize(json); + await Task.Delay(120); + continue; } - return null; + return result; } - catch (JsonException) + finally { - return null; + if (hasLock) + { + IpcLock.Release(); + } } } - } - catch (OperationCanceledException) - { - Debug.WriteLine("[IPC DIAG] Request timed out."); - return null; - } - catch (Exception ex) - { - Debug.WriteLine($"[IPC DIAG] Critical Error: {ex.Message}"); - return null; + return result; } finally { - if (lockTaken) _lock.Release(); + swTotal.Stop(); + result.total_ms = Math.Max(0, (long)swTotal.Elapsed.TotalMilliseconds); } } [Obsolete("Use GetHookDataAsync instead")] public static async Task GetMenuNamesAsync(string clsid, string? contextPath = null) { - var data = await GetHookDataAsync(clsid, contextPath); + 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); } diff --git a/ContextMenuProfiler.UI/Core/Services/HookService.cs b/ContextMenuProfiler.UI/Core/Services/HookService.cs index 29ae68b..185e50c 100644 --- a/ContextMenuProfiler.UI/Core/Services/HookService.cs +++ b/ContextMenuProfiler.UI/Core/Services/HookService.cs @@ -3,6 +3,7 @@ using System.IO; using System.IO.Pipes; using System.Linq; +using System.Threading; using System.Threading.Tasks; using ContextMenuProfiler.UI.Core.Services; @@ -35,6 +36,11 @@ public HookStatus CurrentStatus private const string PipeName = "ContextMenuProfilerHook"; private const string DllName = "ContextMenuProfiler.Hook.dll"; private const string InjectorName = "ContextMenuProfiler.Injector.exe"; + private const int StatusFailureThreshold = 3; + private static readonly TimeSpan ActiveGraceWindow = TimeSpan.FromSeconds(20); + private static readonly TimeSpan DisconnectGraceWindow = TimeSpan.FromMinutes(3); + private int _consecutiveStatusFailures; + private DateTime _lastKnownActiveAtUtc = DateTime.MinValue; private HookService() { @@ -80,15 +86,46 @@ public async Task GetStatusAsync() bool isPipeActive = await CheckPipeAsync(); if (isPipeActive) { + _consecutiveStatusFailures = 0; + _lastKnownActiveAtUtc = DateTime.UtcNow; CurrentStatus = HookStatus.Active; return HookStatus.Active; } + _consecutiveStatusFailures++; + + if (CurrentStatus == HookStatus.Active && + _consecutiveStatusFailures < StatusFailureThreshold && + (DateTime.UtcNow - _lastKnownActiveAtUtc) <= ActiveGraceWindow) + { + return HookStatus.Active; + } + // 2. If pipe failed, check if DLL is actually injected. // This is the "heavy" check, so we do it only if pipe fails. bool isInjected = await Task.Run(() => IsDllInjected()); var status = isInjected ? HookStatus.Injected : HookStatus.Disconnected; + + // Some systems intermittently fail module enumeration or pipe probing after heavy scans. + // If we were recently active, prefer "Injected" instead of flapping to "Disconnected". + if (!isInjected && + CurrentStatus != HookStatus.Disconnected && + _lastKnownActiveAtUtc != DateTime.MinValue && + (DateTime.UtcNow - _lastKnownActiveAtUtc) <= DisconnectGraceWindow) + { + status = HookStatus.Injected; + } + + if (status != HookStatus.Disconnected) + { + _consecutiveStatusFailures = 0; + } + else if (CurrentStatus != HookStatus.Disconnected && _consecutiveStatusFailures < StatusFailureThreshold) + { + return CurrentStatus; + } + CurrentStatus = status; return status; } @@ -128,13 +165,28 @@ private bool IsDllInjected() private async Task CheckPipeAsync() { + bool hasLock = false; try { + // Reuse shared IPC lock to avoid status probe interfering with active benchmark requests. + hasLock = await HookIpcClient.IpcLock.WaitAsync(150); + if (!hasLock) + { + return CurrentStatus == HookStatus.Active; + } + using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); - await client.ConnectAsync(700); + await client.ConnectAsync(1200); return client.IsConnected; } catch { return false; } + finally + { + if (hasLock) + { + HookIpcClient.IpcLock.Release(); + } + } } public async Task InjectAsync() @@ -151,7 +203,13 @@ public async Task InjectAsync() return false; } - return await RunCommandAsync(injectorPath, $"\"{dllPath}\""); + bool ok = await RunCommandAsync(injectorPath, $"\"{dllPath}\""); + if (ok) + { + _lastKnownActiveAtUtc = DateTime.UtcNow; + _consecutiveStatusFailures = 0; + } + return ok; } public async Task EjectAsync() @@ -162,7 +220,14 @@ public async Task EjectAsync() if (string.IsNullOrEmpty(injectorPath)) return false; // For ejection, we just need the filename, but the injector handles path to name conversion - return await RunCommandAsync(injectorPath, $"\"{dllPath}\" --eject"); + bool ok = await RunCommandAsync(injectorPath, $"\"{dllPath}\" --eject"); + if (ok) + { + _lastKnownActiveAtUtc = DateTime.MinValue; + _consecutiveStatusFailures = 0; + CurrentStatus = HookStatus.Disconnected; + } + return ok; } private string FindFile(string fileName) diff --git a/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs new file mode 100644 index 0000000..caf51d3 --- /dev/null +++ b/ContextMenuProfiler.UI/Core/Services/LocalizationService.cs @@ -0,0 +1,296 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace ContextMenuProfiler.UI.Core.Services +{ + public class LanguageOption + { + public string Code { get; set; } = ""; + public string DisplayName { get; set; } = ""; + } + + public class LocalizationService : ObservableObject + { + private static LocalizationService? _instance; + public static LocalizationService Instance => _instance ??= new LocalizationService(); + + private readonly Dictionary> _resources = new(StringComparer.OrdinalIgnoreCase); + private string _currentLanguageCode = "en-US"; + + public ReadOnlyCollection AvailableLanguages { get; } + + public string CurrentLanguageCode + { + get => _currentLanguageCode; + private set => SetProperty(ref _currentLanguageCode, value); + } + + public string this[string key] + { + get + { + if (_resources.TryGetValue(CurrentLanguageCode, out var dict) && dict.TryGetValue(key, out var value)) + { + return value; + } + + if (_resources.TryGetValue("en-US", out var fallback) && fallback.TryGetValue(key, out var fallbackValue)) + { + return fallbackValue; + } + + return key; + } + } + + private LocalizationService() + { + AvailableLanguages = new ReadOnlyCollection(new List + { + new LanguageOption { Code = "auto", DisplayName = "System Default" }, + new LanguageOption { Code = "en-US", DisplayName = "English" }, + new LanguageOption { Code = "zh-CN", DisplayName = "简体中文" } + }); + + _resources["en-US"] = BuildEnglish(); + _resources["zh-CN"] = BuildChinese(); + } + + public void InitializeFromPreferences() + { + var prefs = UserPreferencesService.Load(); + ApplyLanguage(prefs.LanguageCode, false); + } + + public void SetLanguage(string code) + { + ApplyLanguage(code, true); + } + + private void ApplyLanguage(string code, bool persist) + { + code = string.IsNullOrWhiteSpace(code) ? "auto" : code; + string resolved = ResolveLanguageCode(code); + bool languageChanged = !string.Equals(CurrentLanguageCode, resolved, StringComparison.OrdinalIgnoreCase); + if (languageChanged) + { + CurrentLanguageCode = resolved; + OnPropertyChanged("Item[]"); + } + + if (persist) + { + string savedCode = UserPreferencesService.Load().LanguageCode; + if (!string.Equals(savedCode, code, StringComparison.OrdinalIgnoreCase)) + { + UserPreferencesService.Save(new UserPreferences { LanguageCode = code }); + } + } + } + + private static string ResolveLanguageCode(string? code) + { + if (string.IsNullOrWhiteSpace(code) || code.Equals("auto", StringComparison.OrdinalIgnoreCase)) + { + string system = CultureInfo.CurrentUICulture.Name; + if (system.StartsWith("zh", StringComparison.OrdinalIgnoreCase)) + { + return "zh-CN"; + } + return "en-US"; + } + + if (code.Equals("zh-CN", StringComparison.OrdinalIgnoreCase)) + { + return "zh-CN"; + } + + return "en-US"; + } + + private static Dictionary BuildEnglish() + { + return new Dictionary + { + ["App.Title"] = "Context Menu Profiler", + ["Nav.Dashboard"] = "Dashboard", + ["Nav.Settings"] = "Settings", + ["Tray.Home"] = "Home", + ["Status.VersionFormat"] = "Context Menu Profiler v{0}", + ["Hook.NotInjected"] = "Hook: Not Injected", + ["Hook.Inject"] = "Inject Hook", + ["Hook.InjectedIdle"] = "Hook: Injected (Idle)", + ["Hook.Eject"] = "Eject Hook", + ["Hook.Active"] = "Hook: Active", + ["Settings.Title"] = "Settings", + ["Settings.SystemTools"] = "System Tools", + ["Settings.RestartExplorer"] = "Restart Explorer", + ["Settings.RestartExplorerDesc"] = "Restarts Windows Explorer process. Useful when shell extensions are stuck or not loading.", + ["Settings.Restart"] = "Restart", + ["Settings.Language"] = "Language", + ["Settings.LanguageDesc"] = "Change UI language. Applies immediately.", + ["Dialog.ConfirmRestart.Title"] = "Confirm Restart", + ["Dialog.ConfirmRestart.Message"] = "Are you sure you want to restart Windows Explorer?\nThis will temporarily close all folder windows and the taskbar.", + ["Dialog.Error.Title"] = "Error", + ["Dialog.Error.RestartExplorer"] = "Failed to restart Explorer: {0}", + ["Dashboard.ScanSystem"] = "Scan System", + ["Dashboard.AnalyzeFile"] = "Analyze File", + ["Dashboard.Refresh"] = "Refresh (Re-scan)", + ["Dashboard.DeepScan"] = "Deep Scan", + ["Dashboard.DeepScanTip"] = "Scan all file extensions (Slower)", + ["Dashboard.SearchExtensions"] = "Search extensions...", + ["Dashboard.RealWorldLoad"] = "Real-World Load", + ["Dashboard.TotalMenuTime"] = "Total Menu Time", + ["Dashboard.TotalExtensions"] = "Total Extensions", + ["Dashboard.Active"] = "Active", + ["Dashboard.HookWarning"] = "The Hook service is required for accurate load time measurement. If it's disconnected, please try to reconnect.", + ["Dashboard.ReconnectInject"] = "Reconnect / Inject", + ["Dashboard.PerfBreakdown"] = "Performance Breakdown", + ["Dashboard.PerfEstimated"] = "Performance data estimated (Hook unavailable)", + ["Dashboard.Create"] = "Create:", + ["Dashboard.Initialize"] = "Initialize:", + ["Dashboard.Query"] = "Query:", + ["Dashboard.WallClock"] = "Wall Clock:", + ["Dashboard.Total"] = "Total:", + ["Dashboard.Diagnostics"] = "Diagnostics", + ["Dashboard.LockWait"] = "Lock Wait:", + ["Dashboard.PipeConnect"] = "Pipe Connect:", + ["Dashboard.IpcRoundTrip"] = "IPC Round Trip:", + ["Dashboard.Label.Clsid"] = "CLSID:", + ["Dashboard.Label.Binary"] = "Binary:", + ["Dashboard.Label.Details"] = "Details:", + ["Dashboard.Label.Interface"] = "Interface: ", + ["Dashboard.Label.IconSource"] = "Icon Source: ", + ["Dashboard.Label.Threading"] = "Threading: ", + ["Dashboard.Label.RegistryName"] = "Registry Name: ", + ["Dashboard.Label.Registry"] = "Registry:", + ["Dashboard.Label.Package"] = "Package:", + ["Dashboard.Value.Unknown"] = "Unknown", + ["Dashboard.Value.None"] = "None", + ["Dashboard.Action.Copy"] = "Copy", + ["Dashboard.Action.DeletePermanently"] = "Delete Permanently", + ["Dashboard.Sort.LoadDesc"] = "Load Time (High to Low)", + ["Dashboard.Sort.LoadAsc"] = "Load Time (Low to High)", + ["Dashboard.Sort.NameAsc"] = "Name (A-Z)", + ["Dashboard.Sort.Latest"] = "Latest Scanned First", + ["Dashboard.FilterMeasuredOnly"] = "Measured only", + ["Dashboard.FilterMeasuredOnlyTip"] = "Show only entries with valid measured load time (> 0 ms)", + ["Dashboard.Status.ScanningSystem"] = "Scanning system...", + ["Dashboard.Status.ScanningFile"] = "Scanning: {0}", + ["Dashboard.Status.ScanComplete"] = "Scan complete. Found {0} extensions.", + ["Dashboard.Status.ScanFailed"] = "Scan failed.", + ["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.Dialog.SelectFileTitle"] = "Select a file to analyze context menu", + ["Dashboard.Dialog.AllFilesFilter"] = "All files (*.*)|*.*", + ["Dashboard.RealLoad.Measuring"] = "Measuring...", + ["Dashboard.RealLoad.Failed"] = "Failed", + ["Dashboard.RealLoad.Error"] = "Error", + ["Dashboard.Category.All"] = "All", + ["Dashboard.Category.Files"] = "Files", + ["Dashboard.Category.Folders"] = "Folders", + ["Dashboard.Category.Background"] = "Background", + ["Dashboard.Category.Drives"] = "Drives", + ["Dashboard.Category.UwpModern"] = "UWP/Modern", + ["Dashboard.Category.StaticVerbs"] = "Static Verbs" + }; + } + + private static Dictionary BuildChinese() + { + return new Dictionary + { + ["App.Title"] = "右键菜单分析器", + ["Nav.Dashboard"] = "仪表盘", + ["Nav.Settings"] = "设置", + ["Tray.Home"] = "主页", + ["Status.VersionFormat"] = "右键菜单分析器 v{0}", + ["Hook.NotInjected"] = "Hook:未注入", + ["Hook.Inject"] = "注入 Hook", + ["Hook.InjectedIdle"] = "Hook:已注入(空闲)", + ["Hook.Eject"] = "卸载 Hook", + ["Hook.Active"] = "Hook:已连接", + ["Settings.Title"] = "设置", + ["Settings.SystemTools"] = "系统工具", + ["Settings.RestartExplorer"] = "重启资源管理器", + ["Settings.RestartExplorerDesc"] = "重启 Windows 资源管理器进程。适用于 Shell 扩展卡住或未加载的情况。", + ["Settings.Restart"] = "重启", + ["Settings.Language"] = "语言", + ["Settings.LanguageDesc"] = "切换界面语言,立即生效。", + ["Dialog.ConfirmRestart.Title"] = "确认重启", + ["Dialog.ConfirmRestart.Message"] = "确定要重启 Windows 资源管理器吗?\n这会暂时关闭所有文件夹窗口和任务栏。", + ["Dialog.Error.Title"] = "错误", + ["Dialog.Error.RestartExplorer"] = "重启资源管理器失败:{0}", + ["Dashboard.ScanSystem"] = "扫描系统", + ["Dashboard.AnalyzeFile"] = "分析文件", + ["Dashboard.Refresh"] = "刷新(重新扫描)", + ["Dashboard.DeepScan"] = "深度扫描", + ["Dashboard.DeepScanTip"] = "扫描全部文件扩展(更慢)", + ["Dashboard.SearchExtensions"] = "搜索扩展...", + ["Dashboard.RealWorldLoad"] = "真实加载", + ["Dashboard.TotalMenuTime"] = "菜单总耗时", + ["Dashboard.TotalExtensions"] = "扩展总数", + ["Dashboard.Active"] = "已启用", + ["Dashboard.HookWarning"] = "准确测量加载时间需要 Hook 服务。如果断开,请尝试重新连接。", + ["Dashboard.ReconnectInject"] = "重连 / 注入", + ["Dashboard.PerfBreakdown"] = "性能明细", + ["Dashboard.PerfEstimated"] = "性能数据为估算值(Hook 不可用)", + ["Dashboard.Create"] = "创建:", + ["Dashboard.Initialize"] = "初始化:", + ["Dashboard.Query"] = "查询:", + ["Dashboard.WallClock"] = "端到端:", + ["Dashboard.Total"] = "合计:", + ["Dashboard.Diagnostics"] = "诊断", + ["Dashboard.LockWait"] = "锁等待:", + ["Dashboard.PipeConnect"] = "管道连接:", + ["Dashboard.IpcRoundTrip"] = "IPC 往返:", + ["Dashboard.Label.Clsid"] = "CLSID:", + ["Dashboard.Label.Binary"] = "二进制:", + ["Dashboard.Label.Details"] = "详情:", + ["Dashboard.Label.Interface"] = "接口:", + ["Dashboard.Label.IconSource"] = "图标来源:", + ["Dashboard.Label.Threading"] = "线程模型:", + ["Dashboard.Label.RegistryName"] = "注册表名称:", + ["Dashboard.Label.Registry"] = "注册表:", + ["Dashboard.Label.Package"] = "包:", + ["Dashboard.Value.Unknown"] = "未知", + ["Dashboard.Value.None"] = "无", + ["Dashboard.Action.Copy"] = "复制", + ["Dashboard.Action.DeletePermanently"] = "永久删除", + ["Dashboard.Sort.LoadDesc"] = "加载时间(高到低)", + ["Dashboard.Sort.LoadAsc"] = "加载时间(低到高)", + ["Dashboard.Sort.NameAsc"] = "名称(A-Z)", + ["Dashboard.Sort.Latest"] = "最近扫描优先", + ["Dashboard.FilterMeasuredOnly"] = "仅看已测量", + ["Dashboard.FilterMeasuredOnlyTip"] = "仅显示有有效测量耗时(> 0 ms)的项目", + ["Dashboard.Status.ScanningSystem"] = "正在扫描系统...", + ["Dashboard.Status.ScanningFile"] = "正在扫描:{0}", + ["Dashboard.Status.ScanComplete"] = "扫描完成,共找到 {0} 个扩展。", + ["Dashboard.Status.ScanFailed"] = "扫描失败。", + ["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.Dialog.SelectFileTitle"] = "选择要分析右键菜单的文件", + ["Dashboard.Dialog.AllFilesFilter"] = "所有文件 (*.*)|*.*", + ["Dashboard.RealLoad.Measuring"] = "测量中...", + ["Dashboard.RealLoad.Failed"] = "失败", + ["Dashboard.RealLoad.Error"] = "错误", + ["Dashboard.Category.All"] = "全部", + ["Dashboard.Category.Files"] = "文件", + ["Dashboard.Category.Folders"] = "文件夹", + ["Dashboard.Category.Background"] = "背景", + ["Dashboard.Category.Drives"] = "驱动器", + ["Dashboard.Category.UwpModern"] = "UWP/现代扩展", + ["Dashboard.Category.StaticVerbs"] = "静态命令" + }; + } + } +} diff --git a/ContextMenuProfiler.UI/Core/Services/UserPreferencesService.cs b/ContextMenuProfiler.UI/Core/Services/UserPreferencesService.cs new file mode 100644 index 0000000..51bde29 --- /dev/null +++ b/ContextMenuProfiler.UI/Core/Services/UserPreferencesService.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using System.IO; + +namespace ContextMenuProfiler.UI.Core.Services +{ + public class UserPreferences + { + public string LanguageCode { get; set; } = "auto"; + } + + public static class UserPreferencesService + { + private static readonly string PreferencesDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ContextMenuProfiler"); + private static readonly string PreferencesPath = Path.Combine(PreferencesDirectory, "preferences.json"); + + public static UserPreferences Load() + { + try + { + if (!File.Exists(PreferencesPath)) + { + return new UserPreferences(); + } + + string json = File.ReadAllText(PreferencesPath); + var prefs = JsonSerializer.Deserialize(json); + return prefs ?? new UserPreferences(); + } + catch (Exception ex) + { + LogService.Instance.Warning("Failed to load user preferences", ex); + return new UserPreferences(); + } + } + + public static void Save(UserPreferences preferences) + { + try + { + Directory.CreateDirectory(PreferencesDirectory); + string json = JsonSerializer.Serialize(preferences, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(PreferencesPath, json); + } + catch (Exception ex) + { + LogService.Instance.Warning("Failed to save user preferences", ex); + } + } + } +} diff --git a/ContextMenuProfiler.UI/MainWindow.xaml b/ContextMenuProfiler.UI/MainWindow.xaml index 0356199..223ea4c 100644 --- a/ContextMenuProfiler.UI/MainWindow.xaml +++ b/ContextMenuProfiler.UI/MainWindow.xaml @@ -45,7 +45,7 @@ - + diff --git a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs index 6d22f6a..578c914 100644 --- a/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/DashboardViewModel.cs @@ -125,28 +125,21 @@ await App.Current.Dispatcher.InvokeAsync(() => private bool MatchesFilter(BenchmarkResult result) { + if (ShowMeasuredOnly && result.TotalTime <= 0) + { + return false; + } + // Category Match bool categoryMatch = SelectedCategory == "All" || result.Category == SelectedCategory; if (!categoryMatch) return false; - // Optional invalid-result filter (N/A, fallback, static) - if (HideInvalidResults && IsInvalidTimingResult(result)) return false; - // Search Match if (string.IsNullOrWhiteSpace(SearchText)) return true; return result.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || (result.Path != null && result.Path.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); } - private static bool IsInvalidTimingResult(BenchmarkResult result) - { - if (result.Type == "Static") return true; - if (string.Equals(result.Status, "Registry Fallback", StringComparison.OrdinalIgnoreCase)) return true; - if (string.Equals(result.Status, "Load Error", StringComparison.OrdinalIgnoreCase)) return true; - if (string.Equals(result.Status, "Orphaned / Missing DLL", StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - [ObservableProperty] private int _selectedSortIndex = 0; // 0: Time Desc, 1: Time Asc, 2: Name @@ -156,15 +149,7 @@ partial void OnSelectedSortIndexChanged(int value) } [ObservableProperty] - private bool _hideInvalidResults = false; - - partial void OnHideInvalidResultsChanged(bool value) - { - _ = ApplyFilterAsync(); - } - - [ObservableProperty] - private string _statusText = "Ready to scan"; + private string _statusText = LocalizationService.Instance["Dashboard.Status.Ready"]; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RefreshCommand))] @@ -198,11 +183,20 @@ partial void OnIsBusyChanged(bool value) [ObservableProperty] private bool _useDeepScan = false; + [ObservableProperty] + private bool _showMeasuredOnly = false; + + partial void OnShowMeasuredOnlyChanged(bool value) + { + _ = ApplyFilterAsync(); + } + [ObservableProperty] private string _realLoadTime = "N/A"; // Display string for Real Shell Benchmark private string _lastScanMode = "None"; // "System" or "File" private string _lastScanPath = ""; + private long _scanOrderCounter = 0; public HookStatus CurrentHookStatus => HookService.Instance.CurrentStatus; @@ -212,10 +206,10 @@ public string HookStatusMessage { return CurrentHookStatus switch { - HookStatus.Active => "Hook Service Active", - HookStatus.Injected => "DLL Injected, Waiting for Pipe...", - HookStatus.Disconnected => "Hook Disconnected (Injection Required)", - _ => "Unknown Status" + HookStatus.Active => LocalizationService.Instance["Hook.Active"], + HookStatus.Injected => LocalizationService.Instance["Hook.InjectedIdle"], + HookStatus.Disconnected => LocalizationService.Instance["Hook.NotInjected"], + _ => LocalizationService.Instance["Dashboard.Status.Unknown"] }; } } @@ -229,15 +223,19 @@ public DashboardViewModel() // Removed sync ScanResultsView setup // Initialize categories - Categories = new ObservableCollection + ApplyLocalizedCategoryNames(); + LocalizationService.Instance.PropertyChanged += (_, e) => { - new CategoryItem { Name = "All", Tag = "All", Icon = SymbolRegular.TableMultiple20, IsActive = true }, - new CategoryItem { Name = "Files", Tag = "File", Icon = SymbolRegular.Document20 }, - new CategoryItem { Name = "Folders", Tag = "Folder", Icon = SymbolRegular.Folder20 }, - new CategoryItem { Name = "Background", Tag = "Background", Icon = SymbolRegular.Image20 }, - new CategoryItem { Name = "Drives", Tag = "Drive", Icon = SymbolRegular.HardDrive20 }, - new CategoryItem { Name = "UWP/Modern", Tag = "UWP", Icon = SymbolRegular.Box20 }, - new CategoryItem { Name = "Static Verbs", Tag = "Static", Icon = SymbolRegular.PuzzlePiece20 } + if (e.PropertyName == "Item[]") + { + ApplyLocalizedCategoryNames(); + OnPropertyChanged(nameof(CurrentHookStatus)); + OnPropertyChanged(nameof(HookStatusMessage)); + if (!IsBusy) + { + StatusText = LocalizationService.Instance["Dashboard.Status.Ready"]; + } + } }; // Observe Hook status changes to update command availability @@ -326,73 +324,79 @@ private async Task Refresh() private async Task ScanSystem() { _lastScanMode = "System"; - StatusText = "Scanning system..."; + StatusText = LocalizationService.Instance["Dashboard.Status.ScanningSystem"]; IsBusy = true; - bool startedWithHook = HookService.Instance.CurrentStatus != HookStatus.Disconnected; App.Current.Dispatcher.Invoke(() => { + _scanOrderCounter = 0; Results.Clear(); DisplayResults.Clear(); - ResetStats(); }); try { + int pendingUiUpdates = 0; + bool producerCompleted = false; + var uiDrainTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void TryCompleteUiDrain() + { + if (producerCompleted && Volatile.Read(ref pendingUiUpdates) == 0) + { + uiDrainTcs.TrySetResult(true); + } + } + var progressAction = new Action(result => { - InsertSorted(result); - AccumulateStats(result); + // Use Background priority to ensure UI remains smooth during scan + Interlocked.Increment(ref pendingUiUpdates); + var dispatcherTask = App.Current.Dispatcher.InvokeAsync(() => + { + InsertSorted(result); + UpdateStats(); + }, System.Windows.Threading.DispatcherPriority.Background).Task; + + _ = dispatcherTask.ContinueWith(t => + { + Interlocked.Decrement(ref pendingUiUpdates); + if (t.IsFaulted && t.Exception != null) + { + LogService.Instance.Error("Progress UI update failed", t.Exception); + } + TryCompleteUiDrain(); + }, TaskScheduler.Default); }); var mode = UseDeepScan ? ScanMode.Full : ScanMode.Targeted; - await _benchmarkService.RunSystemBenchmarkAsync(mode, new Progress(progressAction)); + await Task.Run(async () => await _benchmarkService.RunSystemBenchmarkAsync(mode, new Progress(progressAction))); + producerCompleted = true; + TryCompleteUiDrain(); + await uiDrainTcs.Task; - StatusText = $"Scan complete. Found {Results.Count} extensions."; - NotificationService.Instance.ShowSuccess("Scan Complete", $"Found {Results.Count} extensions."); + 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)); } catch (Exception ex) { LogService.Instance.Error("Scan System Failed", ex); - StatusText = "Scan failed."; - NotificationService.Instance.ShowError("Scan Failed", ex.Message); + StatusText = LocalizationService.Instance["Dashboard.Status.ScanFailed"]; + NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.ScanFailed.Title"], ex.Message); } finally { IsBusy = false; - - // If hook was available at scan start but got lost during scan, - // try one quick recovery so subsequent scans don't immediately fall back. - if (startedWithHook) - { - try - { - var endStatus = await HookService.Instance.GetStatusAsync(); - if (endStatus == HookStatus.Disconnected) - { - bool recovered = await HookService.Instance.InjectAsync(); - if (recovered) - { - await Task.Delay(300); - endStatus = await HookService.Instance.GetStatusAsync(); - } - - if (endStatus != HookStatus.Active) - { - NotificationService.Instance.ShowWarning("Hook Disconnected", "Hook service disconnected during scan. Results may contain fallback data."); - } - } - } - catch - { - // No-op: scan result is already available. - } - } } } private void InsertSorted(BenchmarkResult newItem) { + if (newItem.ScanOrder <= 0) + { + newItem.ScanOrder = Interlocked.Increment(ref _scanOrderCounter); + } + // Use BinarySearch-based extension for maximum efficiency Results.InsertSorted(newItem, CurrentComparer); @@ -403,38 +407,12 @@ private void InsertSorted(BenchmarkResult newItem) } } - private void ResetStats() - { - TotalExtensions = 0; - DisabledExtensions = 0; - ActiveExtensions = 0; - TotalLoadTime = 0; - ActiveLoadTime = 0; - DisabledLoadTime = 0; - } - - private void AccumulateStats(BenchmarkResult item) - { - TotalExtensions += 1; - TotalLoadTime += item.TotalTime; - - if (item.IsEnabled) - { - ActiveExtensions += 1; - ActiveLoadTime += item.TotalTime; - } - else - { - DisabledExtensions += 1; - DisabledLoadTime += item.TotalTime; - } - } - private Comparison CurrentComparer => SelectedSortIndex switch { 0 => (a, b) => b.TotalTime.CompareTo(a.TotalTime), // Time Desc 1 => (a, b) => a.TotalTime.CompareTo(b.TotalTime), // Time Asc 2 => (a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase), // Name + 3 => (a, b) => b.ScanOrder.CompareTo(a.ScanOrder), // Latest Scanned First _ => (a, b) => b.TotalTime.CompareTo(a.TotalTime) }; @@ -442,8 +420,8 @@ private void AccumulateStats(BenchmarkResult item) private async Task PickAndScanFile() { var dialog = new Microsoft.Win32.OpenFileDialog(); - dialog.Title = "Select a file to analyze context menu"; - dialog.Filter = "All files (*.*)|*.*"; + dialog.Title = LocalizationService.Instance["Dashboard.Dialog.SelectFileTitle"]; + dialog.Filter = LocalizationService.Instance["Dashboard.Dialog.AllFilesFilter"]; if (dialog.ShowDialog() == true) { @@ -458,11 +436,12 @@ private async Task ScanFile(string filePath) _lastScanMode = "File"; _lastScanPath = filePath; - StatusText = $"Scanning: {filePath}"; + StatusText = string.Format(LocalizationService.Instance["Dashboard.Status.ScanningFile"], filePath); IsBusy = true; + _scanOrderCounter = 0; Results.Clear(); DisplayResults.Clear(); // Clear display - RealLoadTime = "Measuring..."; + RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Measuring"]; try { @@ -494,8 +473,8 @@ private async Task ScanFile(string filePath) InsertSorted(res); } UpdateStats(); - StatusText = $"Scan complete. Found {results.Count} extensions."; - NotificationService.Instance.ShowSuccess("Scan Complete", $"Found {results.Count} extensions for {System.IO.Path.GetFileName(filePath)}."); + 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))); } // Run Real-World Benchmark (Parallel but after discovery to avoid COM conflicts if any) @@ -503,10 +482,10 @@ private async Task ScanFile(string filePath) } catch (Exception ex) { - StatusText = "Scan failed."; + StatusText = LocalizationService.Instance["Dashboard.Status.ScanFailed"]; LogService.Instance.Error("File Scan Failed", ex); - NotificationService.Instance.ShowError("Scan Failed", ex.Message); - RealLoadTime = "Error"; + NotificationService.Instance.ShowError(LocalizationService.Instance["Dashboard.Notify.ScanFailed.Title"], ex.Message); + RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Error"]; } finally { @@ -525,12 +504,30 @@ private async Task RunRealBenchmark(string? filePath = null) } else { - RealLoadTime = "Failed"; + RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Failed"]; } } catch { - RealLoadTime = "Error"; + RealLoadTime = LocalizationService.Instance["Dashboard.RealLoad.Error"]; + } + } + + 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 } + }; + if (SelectedCategoryIndex < 0 || SelectedCategoryIndex >= Categories.Count) + { + SelectedCategoryIndex = 0; } } diff --git a/ContextMenuProfiler.UI/ViewModels/MainWindowViewModel.cs b/ContextMenuProfiler.UI/ViewModels/MainWindowViewModel.cs index 07e6ada..49c9fba 100644 --- a/ContextMenuProfiler.UI/ViewModels/MainWindowViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/MainWindowViewModel.cs @@ -8,27 +8,43 @@ using System.Windows.Controls; using System.Threading.Tasks; using System.Windows.Threading; +using System.Reflection; namespace ContextMenuProfiler.UI.ViewModels { public partial class MainWindowViewModel : ObservableObject { [ObservableProperty] - private string _applicationTitle = "Context Menu Profiler"; + private string _applicationTitle = LocalizationService.Instance["App.Title"]; [ObservableProperty] private HookStatus _currentHookStatus = HookStatus.Disconnected; [ObservableProperty] - private string _hookStatusMessage = "Hook: Disconnected"; + private string _hookStatusMessage = LocalizationService.Instance["Hook.NotInjected"]; [ObservableProperty] - private string _hookButtonText = "Inject Hook"; + private string _hookButtonText = LocalizationService.Instance["Hook.Inject"]; + + [ObservableProperty] + private string _statusBarVersionText = ""; private readonly DispatcherTimer _statusTimer; + private readonly string _appVersion; public MainWindowViewModel() { + _appVersion = ResolveAppVersion(); + LocalizationService.Instance.PropertyChanged += (_, e) => + { + if (e.PropertyName == "Item[]") + { + ApplyLocalization(); + _ = UpdateHookStatus(); + } + }; + + ApplyLocalization(); _statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; _statusTimer.Tick += async (s, e) => await UpdateHookStatus(); _statusTimer.Start(); @@ -43,20 +59,62 @@ private async Task UpdateHookStatus() switch (CurrentHookStatus) { case HookStatus.Disconnected: - HookStatusMessage = "Hook: Not Injected"; - HookButtonText = "Inject Hook"; + HookStatusMessage = LocalizationService.Instance["Hook.NotInjected"]; + HookButtonText = LocalizationService.Instance["Hook.Inject"]; break; case HookStatus.Injected: - HookStatusMessage = "Hook: Injected (Idle)"; - HookButtonText = "Eject Hook"; + HookStatusMessage = LocalizationService.Instance["Hook.InjectedIdle"]; + HookButtonText = LocalizationService.Instance["Hook.Eject"]; break; case HookStatus.Active: - HookStatusMessage = "Hook: Active"; - HookButtonText = "Eject Hook"; + HookStatusMessage = LocalizationService.Instance["Hook.Active"]; + HookButtonText = LocalizationService.Instance["Hook.Eject"]; break; } } + private void ApplyLocalization() + { + ApplicationTitle = LocalizationService.Instance["App.Title"]; + StatusBarVersionText = string.Format(LocalizationService.Instance["Status.VersionFormat"], _appVersion); + MenuItems = new ObservableCollection + { + new NavigationViewItem + { + Content = LocalizationService.Instance["Nav.Dashboard"], + Icon = new SymbolIcon { Symbol = SymbolRegular.Home24 }, + TargetPageType = typeof(DashboardPage) + }, + new NavigationViewItem + { + Content = LocalizationService.Instance["Nav.Settings"], + Icon = new SymbolIcon { Symbol = SymbolRegular.Settings24 }, + TargetPageType = typeof(SettingsPage) + } + }; + + TrayMenuItems = new ObservableCollection + { + new System.Windows.Controls.MenuItem { Header = LocalizationService.Instance["Tray.Home"], Tag = "home" } + }; + } + + private static string ResolveAppVersion() + { + var version = Assembly.GetExecutingAssembly().GetName().Version; + if (version == null) + { + return "dev"; + } + + if (version.Build > 0) + { + return $"{version.Major}.{version.Minor}.{version.Build}"; + } + + return $"{version.Major}.{version.Minor}"; + } + [RelayCommand] private async Task ToggleHook() { @@ -72,21 +130,7 @@ private async Task ToggleHook() } [ObservableProperty] - private ObservableCollection _menuItems = new() - { - new NavigationViewItem - { - Content = "Dashboard", - Icon = new SymbolIcon { Symbol = SymbolRegular.Home24 }, - TargetPageType = typeof(DashboardPage) - }, - new NavigationViewItem - { - Content = "Settings", - Icon = new SymbolIcon { Symbol = SymbolRegular.Settings24 }, - TargetPageType = typeof(SettingsPage) - } - }; + private ObservableCollection _menuItems = new(); [ObservableProperty] private ObservableCollection _footerMenuItems = new() @@ -94,9 +138,6 @@ private async Task ToggleHook() }; [ObservableProperty] - private ObservableCollection _trayMenuItems = new() - { - new System.Windows.Controls.MenuItem { Header = "Home", Tag = "home" } - }; + private ObservableCollection _trayMenuItems = new(); } } diff --git a/ContextMenuProfiler.UI/ViewModels/SettingsViewModel.cs b/ContextMenuProfiler.UI/ViewModels/SettingsViewModel.cs index c612d69..2a7ad66 100644 --- a/ContextMenuProfiler.UI/ViewModels/SettingsViewModel.cs +++ b/ContextMenuProfiler.UI/ViewModels/SettingsViewModel.cs @@ -1,17 +1,44 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ContextMenuProfiler.UI.Core.Services; using System.Diagnostics; using System.Windows; using System; +using System.Collections.ObjectModel; namespace ContextMenuProfiler.UI.ViewModels { public partial class SettingsViewModel : ObservableObject { + private bool _isInitializing; + + [ObservableProperty] + private ObservableCollection _languageOptions = new(); + + [ObservableProperty] + private LanguageOption? _selectedLanguage; + + public SettingsViewModel() + { + _isInitializing = true; + LanguageOptions = new ObservableCollection(LocalizationService.Instance.AvailableLanguages); + string savedCode = UserPreferencesService.Load().LanguageCode; + SelectedLanguage = LanguageOptions.FirstOrDefault(l => l.Code.Equals(savedCode, StringComparison.OrdinalIgnoreCase)) + ?? LanguageOptions.FirstOrDefault(l => l.Code == "auto"); + _isInitializing = false; + } + + partial void OnSelectedLanguageChanged(LanguageOption? value) + { + if (value == null) return; + if (_isInitializing) return; + LocalizationService.Instance.SetLanguage(value.Code); + } + [RelayCommand] private void RestartExplorer() { - if (MessageBox.Show("Are you sure you want to restart Windows Explorer?\nThis will temporarily close all folder windows and the taskbar.", "Confirm Restart", MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes) + if (MessageBox.Show(LocalizationService.Instance["Dialog.ConfirmRestart.Message"], LocalizationService.Instance["Dialog.ConfirmRestart.Title"], MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes) { try { @@ -22,7 +49,7 @@ private void RestartExplorer() } catch (Exception ex) { - MessageBox.Show($"Failed to restart Explorer: {ex.Message}", "Error"); + MessageBox.Show(string.Format(LocalizationService.Instance["Dialog.Error.RestartExplorer"], ex.Message), LocalizationService.Instance["Dialog.Error.Title"]); } } } diff --git a/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml b/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml index 5044171..87a41db 100644 --- a/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml +++ b/ContextMenuProfiler.UI/Views/Pages/DashboardPage.xaml @@ -23,6 +23,8 @@ + + @@ -67,13 +69,13 @@ - @@ -89,7 +91,7 @@ - + @@ -103,7 +105,7 @@ - + @@ -117,12 +119,12 @@ - + - + @@ -138,12 +140,12 @@ - - + + - + @@ -181,7 +183,7 @@ - + @@ -189,23 +191,24 @@ - - - - - - - - - + + + + + + + + + + @@ -371,7 +374,7 @@ - + - + + + + + + + @@ -490,48 +525,48 @@ - - - + - + - - + + - - + + - + - + - + @@ -554,7 +589,7 @@ - @@ -565,7 +600,7 @@ - diff --git a/ContextMenuProfiler.UI/Views/Pages/SettingsPage.xaml b/ContextMenuProfiler.UI/Views/Pages/SettingsPage.xaml index 6f15b8a..7fb7141 100644 --- a/ContextMenuProfiler.UI/Views/Pages/SettingsPage.xaml +++ b/ContextMenuProfiler.UI/Views/Pages/SettingsPage.xaml @@ -5,14 +5,15 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:viewmodels="clr-namespace:ContextMenuProfiler.UI.ViewModels" + xmlns:services="clr-namespace:ContextMenuProfiler.UI.Core.Services" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800" - Title="Settings" + Title="{Binding [Settings.Title], Source={x:Static services:LocalizationService.Instance}}" d:DataContext="{d:DesignInstance viewmodels:SettingsViewModel}"> - + @@ -25,8 +26,8 @@ - - + + + + + + + + + + + + + + + + + + + + + +