diff --git a/src/TrayTidy.App/TrayTidy.App.csproj b/src/TrayTidy.App/TrayTidy.App.csproj index 7cbc2f7..4634f6c 100644 --- a/src/TrayTidy.App/TrayTidy.App.csproj +++ b/src/TrayTidy.App/TrayTidy.App.csproj @@ -9,7 +9,7 @@ x64 TrayTidy TrayTidy.App - 0.2.0 + 0.2.1 beta app.manifest Assets\TrayTidy_AppIcon.ico diff --git a/src/TrayTidy.App/ViewModels/MainViewModel.cs b/src/TrayTidy.App/ViewModels/MainViewModel.cs index fc048b1..fb75476 100644 --- a/src/TrayTidy.App/ViewModels/MainViewModel.cs +++ b/src/TrayTidy.App/ViewModels/MainViewModel.cs @@ -598,11 +598,22 @@ private async Task TerminateAndRecordAsync( ApplicationGroup model, string displayName, TerminationMode mode, string sessionId) { var started = DateTimeOffset.UtcNow; - var result = await _termination.TerminateAsync(model, mode); - if (mode == TerminationMode.ForceTree && result.Code == TerminationResultCode.AccessDenied) + TerminationResult result; + try + { + result = await _termination.TerminateAsync(model, mode); + if (mode == TerminationMode.ForceTree && result.Code == TerminationResultCode.AccessDenied) + { + OperationMessage = $"{displayName} 需要管理员权限,正在等待 Windows 确认……"; + result = await _elevatedTermination.TerminateAsAdministratorAsync(model); + } + } + catch (Exception ex) { - OperationMessage = $"{displayName} 需要管理员权限,正在等待 Windows 确认……"; - result = await _elevatedTermination.TerminateAsAdministratorAsync(model); + Log.Error(ex, "Application termination failed unexpectedly; appKey={AppKey}; mode={Mode}", + model.AppKey, mode); + result = new TerminationResult(TerminationResultCode.Failed, + "终止操作遇到意外错误,未确认进程已终止。请查看本地日志。"); } await _cleanup.AddAsync(new CleanupHistoryEntry(0, sessionId, started, DateTimeOffset.UtcNow, model.AppKey, displayName, diff --git a/src/TrayTidy.Infrastructure.Windows/ApplicationTerminationService.cs b/src/TrayTidy.Infrastructure.Windows/ApplicationTerminationService.cs index b241d82..68ae93a 100644 --- a/src/TrayTidy.Infrastructure.Windows/ApplicationTerminationService.cs +++ b/src/TrayTidy.Infrastructure.Windows/ApplicationTerminationService.cs @@ -131,15 +131,31 @@ private static async Task ForceAsync(ApplicationGroup applica { continue; } - catch (Win32Exception ex) when (ex.NativeErrorCode == 5) + catch (Exception ex) when (ex is Win32Exception or UnauthorizedAccessException or + AggregateException or NotSupportedException or TimeoutException) { - return new TerminationResult(TerminationResultCode.AccessDenied, "当前权限不足,无法终止该进程。 "); - } - catch (Exception ex) when (ex is Win32Exception or NotSupportedException or TimeoutException) - { - return new TerminationResult(TerminationResultCode.Failed, $"强制终止失败:{ex.GetType().Name}"); + return MapForceException(ex); } } return new TerminationResult(TerminationResultCode.Success, "已强制终止进程树。 "); } + + internal static TerminationResult MapForceException(Exception exception) + { + if (ContainsAccessDenied(exception)) + return new TerminationResult(TerminationResultCode.AccessDenied, + "当前权限不足,无法终止该进程。 "); + + return new TerminationResult(TerminationResultCode.Failed, + $"强制终止失败:{exception.GetType().Name}"); + } + + private static bool ContainsAccessDenied(Exception exception) + { + if (exception is UnauthorizedAccessException or Win32Exception { NativeErrorCode: 5 }) + return true; + + return exception is AggregateException aggregate && + aggregate.Flatten().InnerExceptions.Any(ContainsAccessDenied); + } } diff --git a/src/TrayTidy.Infrastructure.Windows/AssemblyInfo.cs b/src/TrayTidy.Infrastructure.Windows/AssemblyInfo.cs new file mode 100644 index 0000000..609f79a --- /dev/null +++ b/src/TrayTidy.Infrastructure.Windows/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TrayTidy.Windows.IntegrationTests")] diff --git a/tests/TrayTidy.Windows.IntegrationTests/MainViewModelAiTests.cs b/tests/TrayTidy.Windows.IntegrationTests/MainViewModelAiTests.cs index 27e7fff..27bd912 100644 --- a/tests/TrayTidy.Windows.IntegrationTests/MainViewModelAiTests.cs +++ b/tests/TrayTidy.Windows.IntegrationTests/MainViewModelAiTests.cs @@ -156,12 +156,30 @@ public async Task New_scan_discards_late_ai_result_from_old_generation() public void Auto_analysis_is_disabled_by_default() => Assert.False(AiAdvisorSettings.Default.AutoAnalyzeAfterScan); + [Fact] + public async Task Unexpected_termination_exception_is_reported_without_escaping_the_command() + { + await using var viewModel = CreateViewModel( + new FakeAiCoordinator(), new FakeDialog(), AppSettings.Default, + new ThrowingTermination()); + await viewModel.InitializeAsync(startScan: true); + var item = Assert.Single(viewModel.Applications); + + await viewModel.CloseApplicationCommand.ExecuteAsync(item); + + Assert.True(viewModel.IsOperationVisible); + Assert.False(viewModel.IsOperationBusy); + Assert.False(viewModel.CanForceRemaining); + Assert.Contains("意外错误", viewModel.OperationMessage, StringComparison.Ordinal); + } + private static MainViewModel CreateViewModel( FakeAiCoordinator coordinator, FakeDialog dialog, - AppSettings settings) => new( + AppSettings settings, + IApplicationTerminationService? termination = null) => new( new FakeScanner(), - new FakeTermination(), + termination ?? new FakeTermination(), new FakeElevatedTermination(), new FakeActivation(), new FakeRules(), @@ -328,6 +346,14 @@ public Task TerminateAsync(ApplicationGroup application, Term Task.FromResult(new TerminationResult(TerminationResultCode.Success, "ok")); } + private sealed class ThrowingTermination : IApplicationTerminationService + { + public Task TerminateAsync(ApplicationGroup application, TerminationMode mode, + CancellationToken cancellationToken = default) => + Task.FromException(new AggregateException( + new System.ComponentModel.Win32Exception(5, "Access is denied."))); + } + private sealed class FakeElevatedTermination : IElevatedTerminationService { public Task TerminateAsAdministratorAsync(ApplicationGroup application, CancellationToken cancellationToken = default) => diff --git a/tests/TrayTidy.Windows.IntegrationTests/TerminationSafetyTests.cs b/tests/TrayTidy.Windows.IntegrationTests/TerminationSafetyTests.cs index a1466b3..9d1c205 100644 --- a/tests/TrayTidy.Windows.IntegrationTests/TerminationSafetyTests.cs +++ b/tests/TrayTidy.Windows.IntegrationTests/TerminationSafetyTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using TrayTidy.Core; using TrayTidy.Infrastructure.Windows; @@ -146,6 +147,29 @@ public async Task Resource_activity_does_not_cancel_explicit_graceful_selection( Assert.Contains("没有可用的顶层窗口", result.Message, StringComparison.Ordinal); } + [Fact] + public void Aggregate_access_denied_is_mapped_to_the_elevation_result() + { + var exception = new AggregateException("Not all processes in process tree could be terminated.", + new Win32Exception(5, "Access is denied.")); + + var result = ApplicationTerminationService.MapForceException(exception); + + Assert.Equal(TerminationResultCode.AccessDenied, result.Code); + Assert.Contains("权限不足", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void Aggregate_non_permission_failure_is_reported_without_throwing() + { + var exception = new AggregateException(new Win32Exception(87, "The parameter is incorrect.")); + + var result = ApplicationTerminationService.MapForceException(exception); + + Assert.Equal(TerminationResultCode.Failed, result.Code); + Assert.Contains(nameof(AggregateException), result.Message, StringComparison.Ordinal); + } + private static ApplicationTerminationService CreateService(IReadOnlyList current, out ApplicationGroup application, ProcessSnapshot? original = null) {