From e751bc4f52725719824a29defacc808447b33800 Mon Sep 17 00:00:00 2001 From: redth Date: Mon, 9 Mar 2026 19:12:02 -0400 Subject: [PATCH 01/67] feat: profiling wizard UI, integrated --dsrouter, and pipeline model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor Profiling.razor from single scrolling form into 6-step wizard: Session Setup → Capture Kinds → Target → Review & Plan → Capture → Results - Add horizontal step indicator, Next/Back navigation, step validation - Move advanced session fields into collapsible section - Auto-run prerequisites and plan generation on entering Review step - Add placeholder steps for Capture Running and Results (pipeline runner TBD) - Update ProfilingCaptureOrchestrationService to prefer integrated --dsrouter: dotnet-trace/dotnet-gcdump now use --dsrouter android|android-emu|ios|ios-sim instead of separate dotnet-dsrouter process step - Add ProfilingStopTrigger enum and CanRunParallel/StopTrigger to step model - Set parallel/stop metadata on trace, launch, memory, and log steps - Keep CreateDsRouterStep as documented fallback - Update orchestration tests for new --dsrouter integrated commands - Verify no standalone dsrouter step in mobile plans - Assert CanRunParallel and StopTrigger on generated steps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MauiSherpa.sln | 15 + .../AnalyzeProfilingArtifactHandler.cs | 24 + .../GetProfilingCapabilitiesHandler.cs | 30 + .../Profiling/GetProfilingCatalogHandler.cs | 30 + .../GetProfilingPrerequisitesHandler.cs | 30 + .../Profiling/PlanProfilingCaptureHandler.cs | 27 + src/MauiSherpa.Core/Interfaces.cs | 85 + .../Profiling/ProfilingAnalysisModels.cs | 54 + .../ProfilingArtifactLibraryModels.cs | 21 + .../ProfilingCaptureOrchestrationModels.cs | 133 ++ .../Profiling/ProfilingCatalogModels.cs | 117 ++ .../Models/Profiling/ProfilingModels.cs | 82 + .../Profiling/ProfilingPrerequisitesModels.cs | 55 + .../AnalyzeProfilingArtifactRequest.cs | 6 + .../GetProfilingCapabilitiesRequest.cs | 10 + .../Profiling/GetProfilingCatalogRequest.cs | 10 + .../GetProfilingPrerequisitesRequest.cs | 19 + .../Profiling/PlanProfilingCaptureRequest.cs | 8 + .../Services/CopilotToolsService.cs | 165 +- .../ProfilingArtifactAnalysisService.cs | 1003 ++++++++++ .../ProfilingArtifactLibraryService.cs | 371 ++++ .../ProfilingCaptureOrchestrationService.cs | 760 ++++++++ .../Services/ProfilingCatalogService.cs | 218 +++ .../Services/ProfilingContextService.cs | 322 ++++ .../Services/ProfilingPrerequisitesService.cs | 492 +++++ .../Skills/maui-profiling/SKILL.md | 26 + src/MauiSherpa.MacOS/MacOSApp.cs | 23 +- src/MauiSherpa.MacOS/MacOSMauiProgram.cs | 11 + src/MauiSherpa.ProfilingSample/App.cs | 17 + .../Components/App.razor | 14 + .../Components/MainLayout.razor | 36 + src/MauiSherpa.ProfilingSample/MainPage.cs | 22 + src/MauiSherpa.ProfilingSample/MauiProgram.cs | 36 + .../MauiSherpa.ProfilingSample.csproj | 39 + .../Models/ProfilingScenarioModels.cs | 93 + .../Pages/Home.razor | 415 +++++ .../Platforms/Android/AndroidManifest.xml | 10 + .../Platforms/Android/MainActivity.cs | 18 + .../Platforms/Android/MainApplication.cs | 15 + .../Android/Resources/values/colors.xml | 6 + .../Platforms/MacCatalyst/AppDelegate.cs | 9 + .../Platforms/MacCatalyst/Program.cs | 11 + .../Platforms/iOS/AppDelegate.cs | 9 + .../Platforms/iOS/Info.plist | 32 + .../Platforms/iOS/Program.cs | 11 + .../iOS/Resources/PrivacyInfo.xcprivacy | 41 + .../Services/ProfilingScenarioService.cs | 525 ++++++ src/MauiSherpa.ProfilingSample/_Imports.razor | 10 + .../wwwroot/css/app.css | 527 ++++++ .../wwwroot/index.html | 23 + .../wwwroot/js/profilingSample.js | 64 + src/MauiSherpa/Components/MainLayout.razor | 7 +- src/MauiSherpa/MauiProgram.cs | 11 + src/MauiSherpa/Pages/Profiling.razor | 1649 +++++++++++++++++ .../Raw/Skills/maui-profiling/SKILL.md | 26 + .../AnalyzeProfilingArtifactHandlerTests.cs | 48 + .../GetProfilingCapabilitiesHandlerTests.cs | 58 + .../GetProfilingCatalogHandlerTests.cs | 62 + .../GetProfilingPrerequisitesHandlerTests.cs | 71 + .../PlanProfilingCaptureHandlerTests.cs | 69 + .../Services/CopilotToolsServiceTests.cs | 31 + .../ProfilingArtifactAnalysisServiceTests.cs | 221 +++ .../ProfilingArtifactLibraryServiceTests.cs | 208 +++ ...ofilingCaptureOrchestrationServiceTests.cs | 224 +++ .../Services/ProfilingCatalogServiceTests.cs | 99 + .../Services/ProfilingContextServiceTests.cs | 125 ++ .../ProfilingPrerequisitesServiceTests.cs | 188 ++ 67 files changed, 9212 insertions(+), 15 deletions(-) create mode 100644 src/MauiSherpa.Core/Handlers/Profiling/AnalyzeProfilingArtifactHandler.cs create mode 100644 src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCapabilitiesHandler.cs create mode 100644 src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCatalogHandler.cs create mode 100644 src/MauiSherpa.Core/Handlers/Profiling/GetProfilingPrerequisitesHandler.cs create mode 100644 src/MauiSherpa.Core/Handlers/Profiling/PlanProfilingCaptureHandler.cs create mode 100644 src/MauiSherpa.Core/Models/Profiling/ProfilingAnalysisModels.cs create mode 100644 src/MauiSherpa.Core/Models/Profiling/ProfilingArtifactLibraryModels.cs create mode 100644 src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs create mode 100644 src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs create mode 100644 src/MauiSherpa.Core/Models/Profiling/ProfilingModels.cs create mode 100644 src/MauiSherpa.Core/Models/Profiling/ProfilingPrerequisitesModels.cs create mode 100644 src/MauiSherpa.Core/Requests/Profiling/AnalyzeProfilingArtifactRequest.cs create mode 100644 src/MauiSherpa.Core/Requests/Profiling/GetProfilingCapabilitiesRequest.cs create mode 100644 src/MauiSherpa.Core/Requests/Profiling/GetProfilingCatalogRequest.cs create mode 100644 src/MauiSherpa.Core/Requests/Profiling/GetProfilingPrerequisitesRequest.cs create mode 100644 src/MauiSherpa.Core/Requests/Profiling/PlanProfilingCaptureRequest.cs create mode 100644 src/MauiSherpa.Core/Services/ProfilingArtifactAnalysisService.cs create mode 100644 src/MauiSherpa.Core/Services/ProfilingArtifactLibraryService.cs create mode 100644 src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs create mode 100644 src/MauiSherpa.Core/Services/ProfilingCatalogService.cs create mode 100644 src/MauiSherpa.Core/Services/ProfilingContextService.cs create mode 100644 src/MauiSherpa.Core/Services/ProfilingPrerequisitesService.cs create mode 100644 src/MauiSherpa.Core/Skills/maui-profiling/SKILL.md create mode 100644 src/MauiSherpa.ProfilingSample/App.cs create mode 100644 src/MauiSherpa.ProfilingSample/Components/App.razor create mode 100644 src/MauiSherpa.ProfilingSample/Components/MainLayout.razor create mode 100644 src/MauiSherpa.ProfilingSample/MainPage.cs create mode 100644 src/MauiSherpa.ProfilingSample/MauiProgram.cs create mode 100644 src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj create mode 100644 src/MauiSherpa.ProfilingSample/Models/ProfilingScenarioModels.cs create mode 100644 src/MauiSherpa.ProfilingSample/Pages/Home.razor create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/Android/AndroidManifest.xml create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/Android/MainActivity.cs create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/Android/MainApplication.cs create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/Android/Resources/values/colors.xml create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/AppDelegate.cs create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/Program.cs create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/iOS/AppDelegate.cs create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/iOS/Info.plist create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/iOS/Program.cs create mode 100644 src/MauiSherpa.ProfilingSample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy create mode 100644 src/MauiSherpa.ProfilingSample/Services/ProfilingScenarioService.cs create mode 100644 src/MauiSherpa.ProfilingSample/_Imports.razor create mode 100644 src/MauiSherpa.ProfilingSample/wwwroot/css/app.css create mode 100644 src/MauiSherpa.ProfilingSample/wwwroot/index.html create mode 100644 src/MauiSherpa.ProfilingSample/wwwroot/js/profilingSample.js create mode 100644 src/MauiSherpa/Pages/Profiling.razor create mode 100644 src/MauiSherpa/Resources/Raw/Skills/maui-profiling/SKILL.md create mode 100644 tests/MauiSherpa.Core.Tests/Handlers/Profiling/AnalyzeProfilingArtifactHandlerTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingPrerequisitesHandlerTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Handlers/Profiling/PlanProfilingCaptureHandlerTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactAnalysisServiceTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactLibraryServiceTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Services/ProfilingContextServiceTests.cs create mode 100644 tests/MauiSherpa.Core.Tests/Services/ProfilingPrerequisitesServiceTests.cs diff --git a/MauiSherpa.sln b/MauiSherpa.sln index 99e512bf..3df4556c 100644 --- a/MauiSherpa.sln +++ b/MauiSherpa.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MauiSherpa.LinuxGtk", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MauiSherpa.Cli", "src\MauiSherpa.Cli\MauiSherpa.Cli.csproj", "{5BD8CEB8-EC58-47DE-9C52-1E0BB078B268}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MauiSherpa.ProfilingSample", "src\MauiSherpa.ProfilingSample\MauiSherpa.ProfilingSample.csproj", "{465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +131,18 @@ Global {5BD8CEB8-EC58-47DE-9C52-1E0BB078B268}.Release|x64.Build.0 = Release|Any CPU {5BD8CEB8-EC58-47DE-9C52-1E0BB078B268}.Release|x86.ActiveCfg = Release|Any CPU {5BD8CEB8-EC58-47DE-9C52-1E0BB078B268}.Release|x86.Build.0 = Release|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Debug|x64.ActiveCfg = Debug|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Debug|x64.Build.0 = Debug|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Debug|x86.ActiveCfg = Debug|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Debug|x86.Build.0 = Debug|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Release|Any CPU.Build.0 = Release|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Release|x64.ActiveCfg = Release|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Release|x64.Build.0 = Release|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Release|x86.ActiveCfg = Release|Any CPU + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -142,5 +156,6 @@ Global {7F4B08C8-EC1E-4C00-9F0E-6CFF4169F36B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {985FC27D-16D3-4858-9F6C-4F8E1B76893F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {5BD8CEB8-EC58-47DE-9C52-1E0BB078B268} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {465A3EFF-F52C-42AB-A4FE-3C6C7600DE9A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/MauiSherpa.Core/Handlers/Profiling/AnalyzeProfilingArtifactHandler.cs b/src/MauiSherpa.Core/Handlers/Profiling/AnalyzeProfilingArtifactHandler.cs new file mode 100644 index 00000000..ffb5e696 --- /dev/null +++ b/src/MauiSherpa.Core/Handlers/Profiling/AnalyzeProfilingArtifactHandler.cs @@ -0,0 +1,24 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Handlers.Profiling; + +public partial class AnalyzeProfilingArtifactHandler : IRequestHandler +{ + private readonly IProfilingArtifactAnalysisService _profilingArtifactAnalysisService; + + public AnalyzeProfilingArtifactHandler(IProfilingArtifactAnalysisService profilingArtifactAnalysisService) + { + _profilingArtifactAnalysisService = profilingArtifactAnalysisService; + } + + public async Task Handle( + AnalyzeProfilingArtifactRequest request, + IMediatorContext context, + CancellationToken ct) + { + return await _profilingArtifactAnalysisService.AnalyzeArtifactAsync(request.ArtifactId, ct); + } +} diff --git a/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCapabilitiesHandler.cs b/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCapabilitiesHandler.cs new file mode 100644 index 00000000..38b05e8b --- /dev/null +++ b/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCapabilitiesHandler.cs @@ -0,0 +1,30 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Shiny.Mediator; +using Shiny.Mediator.Caching; + +namespace MauiSherpa.Core.Handlers.Profiling; + +/// +/// Handler for retrieving platform-specific profiling capabilities with medium-lived caching. +/// +public partial class GetProfilingCapabilitiesHandler : IRequestHandler +{ + private readonly IProfilingCatalogService _profilingCatalogService; + + public GetProfilingCapabilitiesHandler(IProfilingCatalogService profilingCatalogService) + { + _profilingCatalogService = profilingCatalogService; + } + + [Cache(AbsoluteExpirationSeconds = 300)] + [OfflineAvailable] + public async Task Handle( + GetProfilingCapabilitiesRequest request, + IMediatorContext context, + CancellationToken ct) + { + return await _profilingCatalogService.GetCapabilitiesAsync(request.Platform, ct); + } +} diff --git a/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCatalogHandler.cs b/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCatalogHandler.cs new file mode 100644 index 00000000..251b4d95 --- /dev/null +++ b/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingCatalogHandler.cs @@ -0,0 +1,30 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Shiny.Mediator; +using Shiny.Mediator.Caching; + +namespace MauiSherpa.Core.Handlers.Profiling; + +/// +/// Handler for retrieving the built-in profiling catalog with medium-lived caching. +/// +public partial class GetProfilingCatalogHandler : IRequestHandler +{ + private readonly IProfilingCatalogService _profilingCatalogService; + + public GetProfilingCatalogHandler(IProfilingCatalogService profilingCatalogService) + { + _profilingCatalogService = profilingCatalogService; + } + + [Cache(AbsoluteExpirationSeconds = 300)] + [OfflineAvailable] + public async Task Handle( + GetProfilingCatalogRequest request, + IMediatorContext context, + CancellationToken ct) + { + return await _profilingCatalogService.GetCatalogAsync(ct); + } +} diff --git a/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingPrerequisitesHandler.cs b/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingPrerequisitesHandler.cs new file mode 100644 index 00000000..0e9b112f --- /dev/null +++ b/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingPrerequisitesHandler.cs @@ -0,0 +1,30 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Shiny.Mediator; +using Shiny.Mediator.Caching; + +namespace MauiSherpa.Core.Handlers.Profiling; + +public partial class GetProfilingPrerequisitesHandler : IRequestHandler +{ + private readonly IProfilingPrerequisitesService _profilingPrerequisitesService; + + public GetProfilingPrerequisitesHandler(IProfilingPrerequisitesService profilingPrerequisitesService) + { + _profilingPrerequisitesService = profilingPrerequisitesService; + } + + [Cache(AbsoluteExpirationSeconds = 120)] + [OfflineAvailable] + public async Task Handle( + GetProfilingPrerequisitesRequest request, + IMediatorContext context, + CancellationToken ct) + { + return await _profilingPrerequisitesService.GetPrerequisitesAsync( + request.Platform, + request.CaptureKinds, + ct: ct); + } +} diff --git a/src/MauiSherpa.Core/Handlers/Profiling/PlanProfilingCaptureHandler.cs b/src/MauiSherpa.Core/Handlers/Profiling/PlanProfilingCaptureHandler.cs new file mode 100644 index 00000000..b1a88216 --- /dev/null +++ b/src/MauiSherpa.Core/Handlers/Profiling/PlanProfilingCaptureHandler.cs @@ -0,0 +1,27 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Handlers.Profiling; + +/// +/// Handler for building validation-friendly profiling capture command plans. +/// +public partial class PlanProfilingCaptureHandler : IRequestHandler +{ + private readonly IProfilingCaptureOrchestrationService _profilingCaptureOrchestrationService; + + public PlanProfilingCaptureHandler(IProfilingCaptureOrchestrationService profilingCaptureOrchestrationService) + { + _profilingCaptureOrchestrationService = profilingCaptureOrchestrationService; + } + + public async Task Handle( + PlanProfilingCaptureRequest request, + IMediatorContext context, + CancellationToken ct) + { + return await _profilingCaptureOrchestrationService.PlanCaptureAsync(request.Definition, request.Options, ct); + } +} diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index d52cbe30..85905d67 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -1,3 +1,4 @@ +using MauiSherpa.Core.Models.Profiling; using MauiSherpa.Core.Services; namespace MauiSherpa.Core.Interfaces; @@ -217,6 +218,73 @@ public interface IScreenCaptureService Task StopRecordingAsync(CancellationToken ct = default); } +// ============================================================================ +// Profiling +// ============================================================================ + +public interface IProfilingCatalogService +{ + Task GetCatalogAsync(CancellationToken ct = default); + Task GetCapabilitiesAsync(ProfilingTargetPlatform platform, CancellationToken ct = default); + ProfilingSessionDefinition CreateSessionDefinition( + ProfilingTarget target, + ProfilingScenarioKind scenario, + string? name = null, + IReadOnlyList? captureKinds = null, + string? appId = null, + TimeSpan? duration = null, + IReadOnlyDictionary? tags = null); + ProfilingSessionValidationResult ValidateSessionDefinition( + ProfilingSessionDefinition definition, + ProfilingPlatformCapabilities capabilities); +} + +public interface IProfilingCapabilityProvider +{ + ProfilingTargetPlatform Platform { get; } + Task GetCapabilitiesAsync(CancellationToken ct = default); +} + +public interface IProfilingPrerequisitesService +{ + Task GetPrerequisitesAsync( + ProfilingTargetPlatform platform, + IReadOnlyList? captureKinds = null, + string? workingDirectory = null, + CancellationToken ct = default); +} + +public interface IProfilingCaptureOrchestrationService +{ + Task PlanCaptureAsync( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions? options = null, + CancellationToken ct = default); +} + +public interface IProfilingArtifactLibraryService +{ + Task> GetArtifactsAsync( + ProfilingArtifactLibraryQuery? query = null, + CancellationToken ct = default); + Task GetArtifactAsync(string artifactId, CancellationToken ct = default); + Task SaveArtifactAsync( + ProfilingArtifactLibrarySaveRequest request, + CancellationToken ct = default); + Task DeleteArtifactAsync(string artifactId, bool deleteFile = false, CancellationToken ct = default); + Task GetArtifactPathAsync(string artifactId, CancellationToken ct = default); + string GetDefaultArtifactDirectory(string sessionId); + event Action? OnArtifactsChanged; +} + +public interface IProfilingArtifactAnalysisService +{ + Task AnalyzeArtifactAsync(string artifactId, CancellationToken ct = default); + Task> AnalyzeArtifactsAsync( + ProfilingArtifactLibraryQuery? query = null, + CancellationToken ct = default); +} + public interface IAndroidSdkSettingsService @@ -2115,6 +2183,22 @@ public interface ICopilotToolsService IReadOnlyList ReadOnlyToolNames { get; } } +/// +/// Builds lightweight, structured profiling context for Copilot without requiring raw trace uploads. +/// +public interface IProfilingContextService +{ + /// + /// Gets the currently available local profiling targets discovered from MauiDevFlow. + /// + Task> GetAvailableTargetsAsync(CancellationToken ct = default); + + /// + /// Builds a lightweight profiling snapshot for the requested target. + /// + Task GetSnapshotAsync(ProfilingSnapshotOptions options, CancellationToken ct = default); +} + /// /// Service for coordinating splash screen visibility between MAUI and Blazor /// @@ -2846,6 +2930,7 @@ public record MauiSherpaSettings public PushTestingSettings PushTesting { get; init; } = new(); public List PushProjects { get; init; } = new(); public List PublishProfiles { get; init; } = new(); + public List ProfilingArtifacts { get; init; } = new(); public DateTime LastModified { get; init; } = DateTime.UtcNow; } diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingAnalysisModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingAnalysisModels.cs new file mode 100644 index 00000000..69073c02 --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingAnalysisModels.cs @@ -0,0 +1,54 @@ +namespace MauiSherpa.Core.Models.Profiling; + +public enum ProfilingAnalysisKind +{ + Metadata, + Speedscope, + Logs, + Json +} + +public enum ProfilingAnalysisInsightSeverity +{ + Info, + Warning, + Critical +} + +public record ProfilingArtifactAnalysisResult( + ProfilingArtifactAnalysis? Analysis, + string? Message = null +); + +public record ProfilingArtifactAnalysis( + ProfilingArtifactMetadata Artifact, + string? ArtifactPath, + bool ArtifactExists, + ProfilingAnalysisKind Kind, + string Summary, + IReadOnlyList Metrics, + IReadOnlyList Hotspots, + IReadOnlyList Insights, + IReadOnlyList Notes +); + +public record ProfilingAnalysisMetric( + string Key, + string Label, + string Value, + double? NumericValue = null, + string? Unit = null +); + +public record ProfilingAnalysisHotspot( + string Name, + string Value, + double PercentOfTrace, + string? Description = null +); + +public record ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity Severity, + string Title, + string Description +); diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingArtifactLibraryModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingArtifactLibraryModels.cs new file mode 100644 index 00000000..1ed2dc77 --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingArtifactLibraryModels.cs @@ -0,0 +1,21 @@ +namespace MauiSherpa.Core.Models.Profiling; + +public record ProfilingArtifactLibraryEntry( + ProfilingArtifactMetadata Metadata, + bool IsManagedPath, + string? SourcePath, + DateTimeOffset AddedAt, + DateTimeOffset UpdatedAt +); + +public record ProfilingArtifactLibraryQuery( + string? SessionId = null, + ProfilingArtifactKind? Kind = null, + bool IncludeMissing = true +); + +public record ProfilingArtifactLibrarySaveRequest( + ProfilingArtifactMetadata Metadata, + string? ArtifactPath = null, + bool CopyToLibrary = false +); diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs new file mode 100644 index 00000000..0012715c --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs @@ -0,0 +1,133 @@ +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Models.Profiling; + +public enum ProfilingCaptureLaunchMode +{ + Launch, + Attach +} + +public enum ProfilingCommandStepKind +{ + Prepare, + Build, + Launch, + DiscoverProcess, + Connect, + Capture, + CollectArtifacts, + Cleanup +} + +public enum ProfilingDiagnosticListenMode +{ + Connect, + Listen +} + +public enum ProfilingDsRouterMode +{ + None, + ServerServer, + ServerClient +} + +public enum ProfilingStopTrigger +{ + None, // Step exits on its own + ManualStop, // User must explicitly stop it + OnPipelineStop // Stopped when the pipeline stops +} + +public record ProfilingCapturePlanOptions( + string? ProjectPath = null, + string Configuration = "Release", + string? TargetFramework = null, + string? WorkingDirectory = null, + string? OutputDirectory = null, + ProfilingCaptureLaunchMode LaunchMode = ProfilingCaptureLaunchMode.Launch, + int DiagnosticPort = 9000, + bool SuspendAtStartup = true, + int? ProcessId = null, + IReadOnlyDictionary? AdditionalBuildProperties = null +); + +public record ProfilingPlanValidation( + IReadOnlyList Errors, + IReadOnlyList Warnings +) +{ + public bool IsValid => Errors.Count == 0; +} + +public record ProfilingDiagnosticConfiguration( + string Address, + int Port, + ProfilingDiagnosticListenMode ListenMode, + bool SuspendOnStartup, + bool RequiresDsRouter, + ProfilingDsRouterMode DsRouterMode, + string? DsRouterPortForwardPlatform, + string IpcAddress, + string TcpEndpoint +); + +public record ProfilingRuntimeBinding( + string Token, + string Description, + bool IsRequired = true, + string? ExampleValue = null +); + +public record ProfilingCommandStep( + string Id, + ProfilingCommandStepKind Kind, + string DisplayName, + string Description, + string Command, + IReadOnlyList Arguments, + string? WorkingDirectory = null, + IReadOnlyDictionary? Environment = null, + IReadOnlyList? DependsOn = null, + IReadOnlyList? RequiredRuntimeBindings = null, + IReadOnlyDictionary? Metadata = null, + bool IsOptional = false, + bool IsLongRunning = false, + bool RequiresManualStop = false, + bool CanRunParallel = false, + ProfilingStopTrigger StopTrigger = ProfilingStopTrigger.None) +{ + public string CommandLine => Arguments.Count > 0 + ? $"{Command} {string.Join(" ", Arguments)}" + : Command; + + public ProcessRequest ToProcessRequest(string? title = null, string? description = null) => new( + Command, + Arguments.ToArray(), + WorkingDirectory, + Environment: Environment?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase), + Title: title ?? DisplayName, + Description: description ?? Description); +} + +public record ProfilingCapturePlan( + ProfilingSessionDefinition Session, + ProfilingPlatformCapabilities Capabilities, + ProfilingCapturePlanOptions Options, + string HostPlatform, + string TargetFramework, + string OutputDirectory, + string? WorkingDirectory, + bool IsTargetCurrentlyAvailable, + ProfilingDiagnosticConfiguration? Diagnostics, + ProfilingPrerequisiteReport? Prerequisites, + ProfilingPlanValidation Validation, + IReadOnlyList RuntimeBindings, + IReadOnlyList Commands, + IReadOnlyList ExpectedArtifacts, + IReadOnlyDictionary Metadata) +{ + public bool RequiresRuntimeInputs => RuntimeBindings.Any(binding => binding.IsRequired); + public bool CanExecute => Validation.IsValid && !RequiresRuntimeInputs; +} diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs new file mode 100644 index 00000000..0715a6f0 --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs @@ -0,0 +1,117 @@ +namespace MauiSherpa.Core.Models.Profiling; + +public enum ProfilingTargetPlatform +{ + Android, + iOS, + MacCatalyst, + MacOS, + Windows +} + +public enum ProfilingTargetKind +{ + PhysicalDevice, + Emulator, + Simulator, + Desktop +} + +public enum ProfilingCaptureKind +{ + Startup, + Cpu, + Memory, + Network, + Rendering, + Energy, + SystemTrace, + Logs +} + +public enum ProfilingScenarioKind +{ + Launch, + Interaction, + Scrolling, + BackgroundWork, + MemoryInvestigation +} + +public enum ProfilingArtifactKind +{ + Trace, + Metrics, + Screenshot, + Logs, + Report, + Export +} + +public record ProfilingTarget( + ProfilingTargetPlatform Platform, + ProfilingTargetKind Kind, + string Identifier, + string DisplayName, + string? OperatingSystemVersion = null, + string? Model = null +); + +public record ProfilingScenarioDefinition( + ProfilingScenarioKind Kind, + string DisplayName, + string Description, + IReadOnlyList DefaultCaptureKinds, + TimeSpan SuggestedDuration, + bool SupportsContinuousCapture = false +); + +public record ProfilingPlatformCapabilities( + ProfilingTargetPlatform Platform, + string DisplayName, + IReadOnlyList SupportedTargetKinds, + IReadOnlyList SupportedCaptureKinds, + IReadOnlyList SupportedArtifactKinds, + IReadOnlyList SupportedScenarios, + bool SupportsLaunchProfiling, + bool SupportsAttachToProcess, + bool SupportsLiveMetrics, + bool SupportsSymbolication, + string? Notes = null +); + +public record ProfilingSessionDefinition( + string Id, + string Name, + ProfilingTarget Target, + ProfilingScenarioKind Scenario, + IReadOnlyList CaptureKinds, + string? AppId = null, + TimeSpan? Duration = null, + IReadOnlyDictionary? Tags = null, + DateTimeOffset CreatedAt = default +); + +public record ProfilingSessionValidationResult( + bool IsValid, + IReadOnlyList Errors, + IReadOnlyList UnsupportedCaptureKinds +); + +public record ProfilingArtifactMetadata( + string Id, + string SessionId, + ProfilingArtifactKind Kind, + string DisplayName, + string FileName, + string? RelativePath, + string ContentType, + DateTimeOffset CreatedAt, + long? SizeBytes = null, + IReadOnlyDictionary? Properties = null +); + +public record ProfilingCatalog( + IReadOnlyList Platforms, + IReadOnlyList Scenarios +); diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingModels.cs new file mode 100644 index 00000000..05c440ba --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingModels.cs @@ -0,0 +1,82 @@ +namespace MauiSherpa.Core.Models.Profiling; + +public record ProfilingTargetInfo( + string Id, + string Host, + int Port, + string DisplayName, + string? AppName, + string? Platform, + string? Project, + string? Tfm, + DateTimeOffset? ConnectedAt, + bool Running, + string DiscoveryMethod +); + +public record ProfilingSnapshotOptions( + string? TargetId = null, + int NetworkSampleSize = 40, + bool IncludeNetworkSummary = true, + bool IncludeVisualTreeSummary = true +); + +public record ProfilingSnapshotResult( + ProfilingSnapshot? Snapshot, + string? Message = null +); + +public record ProfilingSnapshot( + ProfilingTargetInfo Target, + ProfilingRuntimeInfo Runtime, + ProfilingNetworkSummary? Network, + ProfilingVisualTreeSummary? VisualTree, + IReadOnlyList Notes +); + +public record ProfilingRuntimeInfo( + string? Agent, + string? Version, + string? Platform, + string? DeviceType, + string? Idiom, + string? AppName, + bool Running, + bool CdpReady, + int CdpWebViewCount +); + +public record ProfilingNetworkSummary( + int SampleSize, + int SuccessCount, + int FailureCount, + double AverageDurationMs, + double P95DurationMs, + long MaxDurationMs, + long TotalRequestBytes, + long TotalResponseBytes, + IReadOnlyList SlowestRequests +); + +public record ProfilingRequestSummary( + string Method, + string Url, + int? StatusCode, + long DurationMs, + string? Error +); + +public record ProfilingVisualTreeSummary( + int RootCount, + int TotalElementCount, + int VisibleElementCount, + int FocusedElementCount, + int InteractiveElementCount, + int MaxDepth, + IReadOnlyList TopElementTypes +); + +public record ProfilingElementTypeCount( + string Type, + int Count +); diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingPrerequisitesModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingPrerequisitesModels.cs new file mode 100644 index 00000000..90c6b456 --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingPrerequisitesModels.cs @@ -0,0 +1,55 @@ +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Models.Profiling; + +public enum ProfilingPrerequisiteKind +{ + HostPlatform, + DotNetSdk, + DotNetTool, + AndroidToolchain, + AppleToolchain, + WindowsToolchain, + Other +} + +public record ProfilingPrerequisiteContext( + ProfilingTargetPlatform Platform, + IReadOnlyList CaptureKinds, + string? WorkingDirectory, + string? DotNetExecutablePath, + DoctorContext DoctorContext +); + +public record ProfilingPrerequisiteStatus( + string Name, + ProfilingPrerequisiteKind Kind, + DependencyStatusType Status, + bool IsRequired, + string? RequiredVersion, + string? RecommendedVersion, + string? InstalledVersion, + string? Message, + string? DiscoveredBy = null, + string? ExecutablePath = null, + bool IsFixable = false, + string? FixAction = null, + string? SuggestedCommand = null +); + +public record ProfilingPrerequisiteReport( + ProfilingPrerequisiteContext Context, + IReadOnlyList Checks, + DateTimeOffset Timestamp +) +{ + public bool IsReady => Checks + .Where(check => check.IsRequired) + .All(check => check.Status != DependencyStatusType.Error); + + public bool HasErrors => Checks.Any(check => check.Status == DependencyStatusType.Error); + public bool HasWarnings => Checks.Any(check => check.Status == DependencyStatusType.Warning); + public int OkCount => Checks.Count(check => check.Status == DependencyStatusType.Ok || check.Status == DependencyStatusType.Info); + public int WarningCount => Checks.Count(check => check.Status == DependencyStatusType.Warning); + public int ErrorCount => Checks.Count(check => check.Status == DependencyStatusType.Error); +} diff --git a/src/MauiSherpa.Core/Requests/Profiling/AnalyzeProfilingArtifactRequest.cs b/src/MauiSherpa.Core/Requests/Profiling/AnalyzeProfilingArtifactRequest.cs new file mode 100644 index 00000000..ee107b0a --- /dev/null +++ b/src/MauiSherpa.Core/Requests/Profiling/AnalyzeProfilingArtifactRequest.cs @@ -0,0 +1,6 @@ +using MauiSherpa.Core.Models.Profiling; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Requests.Profiling; + +public record AnalyzeProfilingArtifactRequest(string ArtifactId) : IRequest; diff --git a/src/MauiSherpa.Core/Requests/Profiling/GetProfilingCapabilitiesRequest.cs b/src/MauiSherpa.Core/Requests/Profiling/GetProfilingCapabilitiesRequest.cs new file mode 100644 index 00000000..9dade264 --- /dev/null +++ b/src/MauiSherpa.Core/Requests/Profiling/GetProfilingCapabilitiesRequest.cs @@ -0,0 +1,10 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Requests.Profiling; + +public record GetProfilingCapabilitiesRequest(ProfilingTargetPlatform Platform) : IRequest, IContractKey +{ + public string GetKey() => $"profiling:capabilities:{Platform}"; +} diff --git a/src/MauiSherpa.Core/Requests/Profiling/GetProfilingCatalogRequest.cs b/src/MauiSherpa.Core/Requests/Profiling/GetProfilingCatalogRequest.cs new file mode 100644 index 00000000..1ada726b --- /dev/null +++ b/src/MauiSherpa.Core/Requests/Profiling/GetProfilingCatalogRequest.cs @@ -0,0 +1,10 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Requests.Profiling; + +public record GetProfilingCatalogRequest : IRequest, IContractKey +{ + public string GetKey() => "profiling:catalog"; +} diff --git a/src/MauiSherpa.Core/Requests/Profiling/GetProfilingPrerequisitesRequest.cs b/src/MauiSherpa.Core/Requests/Profiling/GetProfilingPrerequisitesRequest.cs new file mode 100644 index 00000000..30cbf110 --- /dev/null +++ b/src/MauiSherpa.Core/Requests/Profiling/GetProfilingPrerequisitesRequest.cs @@ -0,0 +1,19 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Requests.Profiling; + +public record GetProfilingPrerequisitesRequest( + ProfilingTargetPlatform Platform, + IReadOnlyList? CaptureKinds = null) : IRequest, IContractKey +{ + public string GetKey() + { + var normalizedCaptureKinds = CaptureKinds is { Count: > 0 } + ? string.Join(",", CaptureKinds.Distinct().OrderBy(kind => kind)) + : "default"; + + return $"profiling:prerequisites:{Platform}:{normalizedCaptureKinds}"; + } +} diff --git a/src/MauiSherpa.Core/Requests/Profiling/PlanProfilingCaptureRequest.cs b/src/MauiSherpa.Core/Requests/Profiling/PlanProfilingCaptureRequest.cs new file mode 100644 index 00000000..d3a6207b --- /dev/null +++ b/src/MauiSherpa.Core/Requests/Profiling/PlanProfilingCaptureRequest.cs @@ -0,0 +1,8 @@ +using MauiSherpa.Core.Models.Profiling; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Requests.Profiling; + +public record PlanProfilingCaptureRequest( + ProfilingSessionDefinition Definition, + ProfilingCapturePlanOptions? Options = null) : IRequest; diff --git a/src/MauiSherpa.Core/Services/CopilotToolsService.cs b/src/MauiSherpa.Core/Services/CopilotToolsService.cs index c567c531..6b1ae673 100644 --- a/src/MauiSherpa.Core/Services/CopilotToolsService.cs +++ b/src/MauiSherpa.Core/Services/CopilotToolsService.cs @@ -2,11 +2,12 @@ using System.Text.Json; using Microsoft.Extensions.AI; using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; namespace MauiSherpa.Core.Services; /// -/// Provides Copilot SDK tool definitions for Apple Developer and Android SDK operations +/// Provides Copilot SDK tool definitions for Apple Developer, Android SDK, and profiling operations /// public class CopilotToolsService : ICopilotToolsService { @@ -14,6 +15,10 @@ public class CopilotToolsService : ICopilotToolsService private readonly IAppleIdentityStateService _identityState; private readonly IAppleIdentityService _identityService; private readonly IAndroidSdkService _androidService; + private readonly IProfilingCatalogService _profilingCatalogService; + private readonly IProfilingArtifactLibraryService _profilingArtifactLibraryService; + private readonly IProfilingArtifactAnalysisService _profilingArtifactAnalysisService; + private readonly IProfilingContextService _profilingContextService; private readonly ILoggingService _logger; private readonly List _tools = new(); @@ -24,12 +29,20 @@ public CopilotToolsService( IAppleIdentityStateService identityState, IAppleIdentityService identityService, IAndroidSdkService androidService, + IProfilingCatalogService profilingCatalogService, + IProfilingArtifactLibraryService profilingArtifactLibraryService, + IProfilingArtifactAnalysisService profilingArtifactAnalysisService, + IProfilingContextService profilingContextService, ILoggingService logger) { _appleService = appleService; _identityState = identityState; _identityService = identityService; _androidService = androidService; + _profilingCatalogService = profilingCatalogService; + _profilingArtifactLibraryService = profilingArtifactLibraryService; + _profilingArtifactAnalysisService = profilingArtifactAnalysisService; + _profilingContextService = profilingContextService; _logger = logger; InitializeTools(); @@ -143,6 +156,18 @@ private void InitializeTools() "List available Android system images for creating emulators."), isReadOnly: true); AddTool(AIFunctionFactory.Create(ListDeviceDefinitionsAsync, "list_device_definitions", "List available device definitions for creating emulators."), isReadOnly: true); + + // Profiling Tools + AddTool(AIFunctionFactory.Create(ListProfilingTargetsAsync, "list_profiling_targets", + "List currently available local profiling targets discovered via MauiDevFlow."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(GetProfilingCatalogAsync, "get_profiling_catalog", + "Get the supported profiling scenarios and platform capabilities available in Maui Sherpa. Optionally filter to a single platform."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(ListProfilingArtifactsAsync, "list_profiling_artifacts", + "List profiling artifacts stored in Sherpa's artifact library. Optionally filter by session or artifact kind."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(GetProfilingSnapshotAsync, "get_profiling_snapshot", + "Get a lightweight profiling snapshot for a running MAUI app using local status, network, and visual-tree summaries instead of raw trace uploads."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(AnalyzeProfilingArtifactAsync, "analyze_profiling_artifact", + "Analyze a captured profiling artifact from Sherpa's artifact library and return a portable summary with hotspots, metrics, and insights."), isReadOnly: true); } private string? CheckIdentitySelected() @@ -1516,4 +1541,142 @@ private async Task ListDeviceDefinitionsAsync( } #endregion + + #region Profiling Tools + + [Description("List currently available local profiling targets discovered via MauiDevFlow")] + private async Task ListProfilingTargetsAsync() + { + _logger.LogDebug("Tool: list_profiling_targets called"); + + var targets = await _profilingContextService.GetAvailableTargetsAsync(); + if (targets.Count == 0) + { + return "No local profiling targets are available. Start a MAUI app with MauiDevFlow enabled, then try again."; + } + + return JsonSerializer.Serialize(targets, new JsonSerializerOptions { WriteIndented = true }); + } + + [Description("Get supported profiling scenarios and platform capabilities")] + private async Task GetProfilingCatalogAsync( + [Description("Optional platform name to filter to: Android, iOS, MacCatalyst, MacOS, or Windows")] string? platform = null) + { + _logger.LogDebug($"Tool: get_profiling_catalog called with platform '{platform ?? ""}'"); + + var catalog = await _profilingCatalogService.GetCatalogAsync(); + if (string.IsNullOrWhiteSpace(platform)) + { + return JsonSerializer.Serialize(catalog, new JsonSerializerOptions { WriteIndented = true }); + } + + if (!Enum.TryParse(platform, ignoreCase: true, out var parsedPlatform)) + { + return $"Unknown platform '{platform}'. Valid values: {string.Join(", ", Enum.GetNames())}."; + } + + var capabilities = await _profilingCatalogService.GetCapabilitiesAsync(parsedPlatform); + var result = new + { + Platform = capabilities, + Scenarios = catalog.Scenarios + .Where(scenario => capabilities.SupportedScenarios.Contains(scenario.Kind)) + .ToArray() + }; + + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + } + + [Description("Get a lightweight profiling snapshot for a running MAUI app")] + private async Task GetProfilingSnapshotAsync( + [Description("Optional target ID or app name from list_profiling_targets")] string? targetId = null, + [Description("Maximum number of recent network requests to summarize (5-200)")] int networkSampleSize = 40, + [Description("Include a summary of current captured network traffic")] bool includeNetworkSummary = true, + [Description("Include a summary of the current MAUI visual tree")] bool includeVisualTreeSummary = true) + { + _logger.LogDebug($"Tool: get_profiling_snapshot called for target '{targetId ?? ""}'"); + + var result = await _profilingContextService.GetSnapshotAsync( + new ProfilingSnapshotOptions( + TargetId: targetId, + NetworkSampleSize: networkSampleSize, + IncludeNetworkSummary: includeNetworkSummary, + IncludeVisualTreeSummary: includeVisualTreeSummary)); + + if (result.Snapshot == null) + { + return result.Message ?? "Unable to build a profiling snapshot."; + } + + return JsonSerializer.Serialize(result.Snapshot, new JsonSerializerOptions { WriteIndented = true }); + } + + [Description("List captured profiling artifacts stored in Sherpa's artifact library")] + private async Task ListProfilingArtifactsAsync( + [Description("Optional profiling session id to filter to")] string? sessionId = null, + [Description("Optional artifact kind filter: Trace, Metrics, Screenshot, Logs, Report, or Export")] string? kind = null, + [Description("Include artifacts whose backing file is missing")] bool includeMissing = false) + { + _logger.LogDebug($"Tool: list_profiling_artifacts called for session '{sessionId ?? ""}' and kind '{kind ?? ""}'"); + + ProfilingArtifactKind? parsedKind = null; + if (!string.IsNullOrWhiteSpace(kind)) + { + if (!Enum.TryParse(kind, ignoreCase: true, out var parsed)) + { + return $"Unknown artifact kind '{kind}'. Valid values: {string.Join(", ", Enum.GetNames())}."; + } + + parsedKind = parsed; + } + + var artifacts = await _profilingArtifactLibraryService.GetArtifactsAsync( + new ProfilingArtifactLibraryQuery( + SessionId: sessionId, + Kind: parsedKind, + IncludeMissing: includeMissing)); + + if (artifacts.Count == 0) + { + return "No profiling artifacts matched the requested filters."; + } + + var result = new List(artifacts.Count); + foreach (var artifact in artifacts) + { + var artifactPath = await _profilingArtifactLibraryService.GetArtifactPathAsync(artifact.Metadata.Id); + result.Add(new + { + artifact.Metadata.Id, + artifact.Metadata.SessionId, + artifact.Metadata.Kind, + artifact.Metadata.DisplayName, + artifact.Metadata.FileName, + artifact.Metadata.CreatedAt, + artifact.Metadata.SizeBytes, + artifact.Metadata.Properties, + artifact.IsManagedPath, + ArtifactExists = !string.IsNullOrWhiteSpace(artifactPath) && File.Exists(artifactPath) + }); + } + + return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + } + + [Description("Analyze a captured profiling artifact from Sherpa's artifact library")] + private async Task AnalyzeProfilingArtifactAsync( + [Description("Artifact id returned by list_profiling_artifacts")] string artifactId) + { + _logger.LogDebug($"Tool: analyze_profiling_artifact called for artifact '{artifactId}'"); + + var result = await _profilingArtifactAnalysisService.AnalyzeArtifactAsync(artifactId); + if (result.Analysis is null) + { + return result.Message ?? $"Profiling artifact '{artifactId}' could not be analyzed."; + } + + return JsonSerializer.Serialize(result.Analysis, new JsonSerializerOptions { WriteIndented = true }); + } + + #endregion } diff --git a/src/MauiSherpa.Core/Services/ProfilingArtifactAnalysisService.cs b/src/MauiSherpa.Core/Services/ProfilingArtifactAnalysisService.cs new file mode 100644 index 00000000..4b9e5d1a --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingArtifactAnalysisService.cs @@ -0,0 +1,1003 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public class ProfilingArtifactAnalysisService : IProfilingArtifactAnalysisService +{ + private static readonly Regex HexValueRegex = new(@"0x[0-9a-fA-F]+", RegexOptions.Compiled); + private static readonly Regex IntegerRegex = new(@"\b\d+\b", RegexOptions.Compiled); + private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + private static readonly Regex TimestampPrefixRegex = new( + @"^\s*(?:\[[^\]]+\]\s*)?(?\d{4}-\d{2}-\d{2}[T ][0-9:\.,+\-Z]+|\d{2}:\d{2}:\d{2}(?:[\.,]\d+)?)\s*", + RegexOptions.Compiled); + + private readonly IProfilingArtifactLibraryService _profilingArtifactLibraryService; + private readonly ILoggingService _loggingService; + + public ProfilingArtifactAnalysisService( + IProfilingArtifactLibraryService profilingArtifactLibraryService, + ILoggingService loggingService) + { + _profilingArtifactLibraryService = profilingArtifactLibraryService; + _loggingService = loggingService; + } + + public async Task AnalyzeArtifactAsync(string artifactId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(artifactId)) + { + throw new ArgumentException("A profiling artifact id is required.", nameof(artifactId)); + } + + var entry = await _profilingArtifactLibraryService.GetArtifactAsync(artifactId, ct); + if (entry is null) + { + return new ProfilingArtifactAnalysisResult( + null, + $"Profiling artifact '{artifactId}' was not found in the artifact library."); + } + + var analysis = await AnalyzeEntryAsync(entry, ct); + return new ProfilingArtifactAnalysisResult(analysis); + } + + public async Task> AnalyzeArtifactsAsync( + ProfilingArtifactLibraryQuery? query = null, + CancellationToken ct = default) + { + var artifacts = await _profilingArtifactLibraryService.GetArtifactsAsync(query, ct); + var analyses = new List(artifacts.Count); + + foreach (var artifact in artifacts) + { + ct.ThrowIfCancellationRequested(); + analyses.Add(await AnalyzeEntryAsync(artifact, ct)); + } + + return analyses; + } + + private async Task AnalyzeEntryAsync( + ProfilingArtifactLibraryEntry artifact, + CancellationToken ct) + { + var artifactPath = await _profilingArtifactLibraryService.GetArtifactPathAsync(artifact.Metadata.Id, ct); + var artifactExists = !string.IsNullOrWhiteSpace(artifactPath) && File.Exists(artifactPath); + + if (!artifactExists) + { + return CreateMetadataAnalysis( + artifact.Metadata, + artifactPath, + artifactExists: false, + summary: $"The artifact file for {artifact.Metadata.DisplayName} is no longer available on disk.", + insights: + [ + new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Warning, + "Artifact file is missing", + "The artifact metadata is still available, but the referenced file could not be found.") + ]); + } + + try + { + if (IsJsonArtifact(artifact.Metadata, artifactPath!)) + { + using var stream = File.OpenRead(artifactPath!); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + + if (LooksLikeSpeedscope(document.RootElement, artifact.Metadata)) + { + var speedscopeAnalysis = AnalyzeSpeedscopeArtifact(artifact.Metadata, artifactPath!, document.RootElement); + if (speedscopeAnalysis is not null) + { + return speedscopeAnalysis; + } + } + + return AnalyzeJsonArtifact(artifact.Metadata, artifactPath!, document.RootElement); + } + + if (IsLogArtifact(artifact.Metadata, artifactPath!)) + { + return AnalyzeLogArtifact(artifact.Metadata, artifactPath!); + } + } + catch (JsonException ex) + { + _loggingService.LogDebug($"Portable profiling analysis could not parse JSON for '{artifact.Metadata.Id}': {ex.Message}"); + return CreateMetadataAnalysis( + artifact.Metadata, + artifactPath, + artifactExists: true, + summary: $"Sherpa found {artifact.Metadata.DisplayName}, but the JSON payload could not be analyzed as a portable summary.", + notes: ["The artifact may require a specialized viewer or exporter to inspect in detail."], + insights: + [ + new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Warning, + "JSON artifact could not be parsed", + ex.Message) + ]); + } + catch (Exception ex) + { + _loggingService.LogError($"Failed to analyze profiling artifact '{artifact.Metadata.Id}': {ex.Message}", ex); + return CreateMetadataAnalysis( + artifact.Metadata, + artifactPath, + artifactExists: true, + summary: $"Sherpa found {artifact.Metadata.DisplayName}, but portable analysis fell back to metadata because the artifact could not be parsed.", + insights: + [ + new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Warning, + "Portable analysis failed", + ex.Message) + ]); + } + + return CreateMetadataAnalysis( + artifact.Metadata, + artifactPath, + artifactExists: true, + summary: CreateMetadataSummary(artifact.Metadata), + notes: [CreateMetadataNote(artifact.Metadata)]); + } + + private static ProfilingArtifactAnalysis? AnalyzeSpeedscopeArtifact( + ProfilingArtifactMetadata metadata, + string artifactPath, + JsonElement root) + { + if (!TryGetProperty(root, "profiles", out var profilesElement) || profilesElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + if (!TryGetProperty(root, "shared", out var sharedElement) || + !TryGetProperty(sharedElement, "frames", out var framesElement) || + framesElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var frames = framesElement + .EnumerateArray() + .Select(ParseFrame) + .ToArray(); + + var aggregate = new SpeedscopeAggregate(); + foreach (var profile in profilesElement.EnumerateArray()) + { + var type = GetString(profile, "type"); + if (string.Equals(type, "sampled", StringComparison.OrdinalIgnoreCase)) + { + AnalyzeSampledProfile(profile, aggregate); + } + else if (string.Equals(type, "evented", StringComparison.OrdinalIgnoreCase)) + { + AnalyzeEventedProfile(profile, aggregate); + } + else if (!string.IsNullOrWhiteSpace(type)) + { + aggregate.Notes.Add($"Skipped unsupported speedscope profile type '{type}'."); + } + } + + if (aggregate.ProfileCount == 0) + { + return null; + } + + var hotspots = aggregate.InclusiveWeights + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => frames[pair.Key].Name, StringComparer.OrdinalIgnoreCase) + .Take(5) + .Select(pair => + { + var frame = frames[pair.Key]; + var percent = aggregate.TotalReferenceWeight <= 0 + ? 0 + : Math.Round((pair.Value / aggregate.TotalReferenceWeight) * 100, 1); + var location = frame.File is null + ? null + : frame.Line.HasValue + ? $"{frame.File}:{frame.Line.Value}" + : frame.File; + + return new ProfilingAnalysisHotspot( + frame.Name, + FormatReferenceValue(pair.Value, aggregate), + percent, + location); + }) + .ToArray(); + + var metrics = CreateBaseMetrics(metadata); + metrics.Add(new ProfilingAnalysisMetric("profileCount", "Profiles", aggregate.ProfileCount.ToString(CultureInfo.InvariantCulture), aggregate.ProfileCount)); + metrics.Add(new ProfilingAnalysisMetric( + "samples", + aggregate.EventedProfileCount > 0 && aggregate.SampledProfileCount == 0 ? "Events" : "Samples", + aggregate.ReferenceEventCount.ToString(CultureInfo.InvariantCulture), + aggregate.ReferenceEventCount)); + metrics.Add(new ProfilingAnalysisMetric("frames", "Observed frames", aggregate.ObservedFrames.Count.ToString(CultureInfo.InvariantCulture), aggregate.ObservedFrames.Count)); + + if (aggregate.TotalDurationMs > 0) + { + metrics.Add(new ProfilingAnalysisMetric( + "durationMs", + "Duration", + $"{aggregate.TotalDurationMs:0.##} ms", + aggregate.TotalDurationMs, + "ms")); + } + + if (hotspots.Length > 0) + { + metrics.Add(new ProfilingAnalysisMetric( + "topHotspotPercent", + "Top hotspot share", + $"{hotspots[0].PercentOfTrace:0.#}%", + hotspots[0].PercentOfTrace, + "%")); + } + + var insights = new List(); + if (hotspots.Length > 0 && hotspots[0].PercentOfTrace >= 45) + { + insights.Add(new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Warning, + "Single hotspot dominates the trace", + $"{hotspots[0].Name} accounts for {hotspots[0].PercentOfTrace:0.#}% of the analyzed trace.")); + } + + if (aggregate.SampledProfileCount > 0 && aggregate.ReferenceEventCount < 25) + { + insights.Add(new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Info, + "Low sample count", + "The sampled trace has limited coverage, so hotspot rankings may shift with a longer capture.")); + } + + if (aggregate.ProfileCount > 1) + { + insights.Add(new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Info, + "Multiple profiles analyzed", + $"The artifact contains {aggregate.ProfileCount} profiles. Hotspots are aggregated across them.")); + } + + if (aggregate.UsesSampleWeights) + { + aggregate.Notes.Add("Hotspot percentages are based on inclusive weighted samples exported in the speedscope trace."); + } + else if (aggregate.SampledProfileCount > 0) + { + aggregate.Notes.Add("Hotspot percentages are based on how often a frame appears in sampled stacks, not exact CPU time."); + } + + var summary = hotspots.Length > 0 + ? $"Trace capture spans about {FormatDurationSummary(aggregate.TotalDurationMs)} with {hotspots[0].Name} as the top hotspot at {hotspots[0].PercentOfTrace:0.#}% of the analyzed trace." + : $"Trace capture includes {aggregate.ProfileCount} profile(s) and {aggregate.ObservedFrames.Count} observed frame(s)."; + + return new ProfilingArtifactAnalysis( + metadata, + artifactPath, + ArtifactExists: true, + Kind: ProfilingAnalysisKind.Speedscope, + Summary: summary, + Metrics: metrics, + Hotspots: hotspots, + Insights: insights, + Notes: aggregate.Notes); + } + + private static ProfilingArtifactAnalysis AnalyzeLogArtifact( + ProfilingArtifactMetadata metadata, + string artifactPath) + { + var lineCount = 0; + var warningCount = 0; + var errorCount = 0; + var exceptionCount = 0; + DateTimeOffset? firstTimestamp = null; + DateTimeOffset? lastTimestamp = null; + var frequentLines = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var line in File.ReadLines(artifactPath)) + { + lineCount++; + var normalized = NormalizeLogMessage(line); + if (!string.IsNullOrWhiteSpace(normalized)) + { + frequentLines[normalized] = frequentLines.TryGetValue(normalized, out var count) ? count + 1 : 1; + } + + var lowerLine = line.ToLowerInvariant(); + if (lowerLine.Contains("exception", StringComparison.Ordinal)) + { + exceptionCount++; + } + + if (lowerLine.Contains("error", StringComparison.Ordinal) || + lowerLine.Contains("fatal", StringComparison.Ordinal)) + { + errorCount++; + } + else if (lowerLine.Contains("warn", StringComparison.Ordinal)) + { + warningCount++; + } + + var timestamp = TryParseLogTimestamp(line); + if (timestamp is not null) + { + firstTimestamp ??= timestamp; + lastTimestamp = timestamp; + } + } + + var hotspots = frequentLines + .Where(pair => pair.Value > 1) + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Take(5) + .Select(pair => new ProfilingAnalysisHotspot( + pair.Key, + $"{pair.Value} occurrence{(pair.Value == 1 ? string.Empty : "s")}", + lineCount == 0 ? 0 : Math.Round((pair.Value / (double)lineCount) * 100, 1))) + .ToArray(); + + var metrics = CreateBaseMetrics(metadata); + metrics.Add(new ProfilingAnalysisMetric("lineCount", "Log lines", lineCount.ToString(CultureInfo.InvariantCulture), lineCount)); + metrics.Add(new ProfilingAnalysisMetric("warningCount", "Warnings", warningCount.ToString(CultureInfo.InvariantCulture), warningCount)); + metrics.Add(new ProfilingAnalysisMetric("errorCount", "Errors", errorCount.ToString(CultureInfo.InvariantCulture), errorCount)); + metrics.Add(new ProfilingAnalysisMetric("exceptionCount", "Exception mentions", exceptionCount.ToString(CultureInfo.InvariantCulture), exceptionCount)); + + var notes = new List(); + if (firstTimestamp is not null && lastTimestamp is not null && lastTimestamp >= firstTimestamp) + { + var duration = lastTimestamp.Value - firstTimestamp.Value; + metrics.Add(new ProfilingAnalysisMetric( + "durationMs", + "Duration", + $"{duration.TotalMilliseconds:0.##} ms", + duration.TotalMilliseconds, + "ms")); + } + else + { + notes.Add("No parseable timestamps were found, so Sherpa could not estimate log duration."); + } + + var insights = new List(); + if (errorCount > 0) + { + insights.Add(new ProfilingAnalysisInsight( + exceptionCount > 0 ? ProfilingAnalysisInsightSeverity.Critical : ProfilingAnalysisInsightSeverity.Warning, + "Errors detected in captured logs", + $"The artifact contains {errorCount} error-level line(s) and {exceptionCount} exception mention(s).")); + } + else if (warningCount > 0) + { + insights.Add(new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Info, + "Warnings detected in captured logs", + $"The artifact contains {warningCount} warning-level line(s).")); + } + + if (hotspots.Length > 0) + { + insights.Add(new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Info, + "Recurring log lines found", + $"The most frequent recurring line appears {hotspots[0].Value} in the captured logs.")); + } + + var summary = $"Parsed {lineCount} log line(s) with {warningCount} warning(s), {errorCount} error(s), and {exceptionCount} exception mention(s)."; + + return new ProfilingArtifactAnalysis( + metadata, + artifactPath, + ArtifactExists: true, + Kind: ProfilingAnalysisKind.Logs, + Summary: summary, + Metrics: metrics, + Hotspots: hotspots, + Insights: insights, + Notes: notes); + } + + private static ProfilingArtifactAnalysis AnalyzeJsonArtifact( + ProfilingArtifactMetadata metadata, + string artifactPath, + JsonElement root) + { + var metrics = CreateBaseMetrics(metadata); + var hotspots = new List(); + var insights = new List(); + var notes = new List(); + string summary; + + switch (root.ValueKind) + { + case JsonValueKind.Object: + { + var properties = root.EnumerateObject().ToArray(); + metrics.Add(new ProfilingAnalysisMetric( + "propertyCount", + "Top-level properties", + properties.Length.ToString(CultureInfo.InvariantCulture), + properties.Length)); + + var arrayCount = 0; + foreach (var property in properties) + { + switch (property.Value.ValueKind) + { + case JsonValueKind.Array: + arrayCount++; + hotspots.Add(new ProfilingAnalysisHotspot( + ToDisplayLabel(property.Name), + $"{property.Value.GetArrayLength()} item(s)", + 0)); + break; + case JsonValueKind.Number when property.Value.TryGetDouble(out var numericValue): + metrics.Add(new ProfilingAnalysisMetric( + property.Name, + ToDisplayLabel(property.Name), + numericValue.ToString("0.##", CultureInfo.InvariantCulture), + numericValue)); + if (IsAlertMetric(property.Name, numericValue)) + { + insights.Add(new ProfilingAnalysisInsight( + ProfilingAnalysisInsightSeverity.Warning, + $"{ToDisplayLabel(property.Name)} is elevated", + $"The JSON artifact reports {numericValue:0.##} for {ToDisplayLabel(property.Name)}.")); + } + break; + case JsonValueKind.True: + case JsonValueKind.False: + metrics.Add(new ProfilingAnalysisMetric( + property.Name, + ToDisplayLabel(property.Name), + property.Value.GetBoolean() ? "True" : "False")); + break; + } + } + + summary = arrayCount > 0 + ? $"Parsed a structured JSON artifact with {properties.Length} top-level properties and {arrayCount} collection(s)." + : $"Parsed a structured JSON artifact with {properties.Length} top-level properties."; + break; + } + case JsonValueKind.Array: + { + var count = root.GetArrayLength(); + metrics.Add(new ProfilingAnalysisMetric("itemCount", "Items", count.ToString(CultureInfo.InvariantCulture), count)); + summary = $"Parsed a JSON artifact containing {count} top-level item(s)."; + break; + } + default: + summary = "Parsed a JSON artifact, but it did not contain an object or array that Sherpa could summarize in a structured way."; + notes.Add("Consider exporting the artifact in a richer report format for deeper analysis."); + break; + } + + if (hotspots.Count > 5) + { + hotspots = hotspots.Take(5).ToList(); + } + + return new ProfilingArtifactAnalysis( + metadata, + artifactPath, + ArtifactExists: true, + Kind: ProfilingAnalysisKind.Json, + Summary: summary, + Metrics: metrics, + Hotspots: hotspots, + Insights: insights, + Notes: notes); + } + + private static void AnalyzeSampledProfile(JsonElement profile, SpeedscopeAggregate aggregate) + { + aggregate.ProfileCount++; + aggregate.SampledProfileCount++; + + var unit = GetString(profile, "unit"); + var startValue = TryGetDouble(profile, "startValue"); + var endValue = TryGetDouble(profile, "endValue"); + if (startValue.HasValue && endValue.HasValue && endValue.Value >= startValue.Value) + { + aggregate.TotalDurationMs += ConvertToMilliseconds(endValue.Value - startValue.Value, unit); + } + + if (!TryGetProperty(profile, "samples", out var samplesElement) || samplesElement.ValueKind != JsonValueKind.Array) + { + aggregate.Notes.Add("A sampled profile was missing the samples array."); + return; + } + + var weights = Array.Empty(); + if (TryGetProperty(profile, "weights", out var weightsElement) && weightsElement.ValueKind == JsonValueKind.Array) + { + weights = weightsElement.EnumerateArray() + .Select(weight => weight.ValueKind == JsonValueKind.Number && weight.TryGetDouble(out var value) ? value : 1d) + .ToArray(); + aggregate.UsesSampleWeights = true; + } + + var referenceMode = weights.Length > 0 && CanConvertToMilliseconds(unit) + ? SpeedscopeReferenceMode.DurationMs + : SpeedscopeReferenceMode.SampleWeight; + aggregate.SetReferenceMode(referenceMode); + + var index = 0; + foreach (var sample in samplesElement.EnumerateArray()) + { + var weight = index < weights.Length ? weights[index] : 1d; + var normalizedWeight = referenceMode == SpeedscopeReferenceMode.DurationMs + ? ConvertToMilliseconds(weight, unit) + : weight; + + aggregate.TotalReferenceWeight += normalizedWeight; + aggregate.ReferenceEventCount++; + + var uniqueFrames = new HashSet(); + foreach (var frameValue in sample.EnumerateArray()) + { + var frameIndex = TryGetInt32(frameValue); + if (!frameIndex.HasValue) + { + continue; + } + + aggregate.ObservedFrames.Add(frameIndex.Value); + uniqueFrames.Add(frameIndex.Value); + } + + foreach (var frameIndex in uniqueFrames) + { + aggregate.InclusiveWeights[frameIndex] = aggregate.InclusiveWeights.TryGetValue(frameIndex, out var existing) + ? existing + normalizedWeight + : normalizedWeight; + } + + index++; + } + } + + private static void AnalyzeEventedProfile(JsonElement profile, SpeedscopeAggregate aggregate) + { + aggregate.ProfileCount++; + aggregate.EventedProfileCount++; + + var unit = GetString(profile, "unit"); + var startValue = TryGetDouble(profile, "startValue"); + var endValue = TryGetDouble(profile, "endValue"); + var durationMs = startValue.HasValue && endValue.HasValue && endValue.Value >= startValue.Value + ? ConvertToMilliseconds(endValue.Value - startValue.Value, unit) + : 0d; + if (durationMs > 0) + { + aggregate.TotalDurationMs += durationMs; + } + + aggregate.SetReferenceMode(SpeedscopeReferenceMode.DurationMs); + + if (!TryGetProperty(profile, "events", out var eventsElement) || eventsElement.ValueKind != JsonValueKind.Array) + { + aggregate.Notes.Add("An evented profile was missing the events array."); + return; + } + + var stack = new Stack<(int FrameIndex, double StartedAt)>(); + var minTimestamp = double.MaxValue; + var maxTimestamp = double.MinValue; + + foreach (var @event in eventsElement.EnumerateArray()) + { + aggregate.ReferenceEventCount++; + + var eventType = GetString(@event, "type"); + var eventAt = TryGetDouble(@event, "at"); + var frameIndex = TryGetInt32(@event, "frame"); + if (eventAt is null || frameIndex is null) + { + continue; + } + + minTimestamp = Math.Min(minTimestamp, eventAt.Value); + maxTimestamp = Math.Max(maxTimestamp, eventAt.Value); + aggregate.ObservedFrames.Add(frameIndex.Value); + + if (string.Equals(eventType, "O", StringComparison.OrdinalIgnoreCase)) + { + stack.Push((frameIndex.Value, eventAt.Value)); + continue; + } + + if (!string.Equals(eventType, "C", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!TryPopMatchingFrame(stack, frameIndex.Value, out var openedFrame)) + { + continue; + } + + var duration = Math.Max(0, eventAt.Value - openedFrame.StartedAt); + var normalizedDuration = ConvertToMilliseconds(duration, unit); + aggregate.InclusiveWeights[frameIndex.Value] = aggregate.InclusiveWeights.TryGetValue(frameIndex.Value, out var existing) + ? existing + normalizedDuration + : normalizedDuration; + } + + if (durationMs <= 0 && minTimestamp != double.MaxValue && maxTimestamp >= minTimestamp) + { + aggregate.TotalDurationMs += ConvertToMilliseconds(maxTimestamp - minTimestamp, unit); + } + + aggregate.TotalReferenceWeight += durationMs > 0 + ? durationMs + : minTimestamp != double.MaxValue && maxTimestamp >= minTimestamp + ? ConvertToMilliseconds(maxTimestamp - minTimestamp, unit) + : 0; + } + + private static ProfilingArtifactAnalysis CreateMetadataAnalysis( + ProfilingArtifactMetadata metadata, + string? artifactPath, + bool artifactExists, + string summary, + IReadOnlyList? notes = null, + IReadOnlyList? insights = null) + { + return new ProfilingArtifactAnalysis( + metadata, + artifactPath, + artifactExists, + ProfilingAnalysisKind.Metadata, + summary, + CreateBaseMetrics(metadata), + Array.Empty(), + insights ?? Array.Empty(), + notes ?? Array.Empty()); + } + + private static List CreateBaseMetrics(ProfilingArtifactMetadata metadata) + { + var metrics = new List(); + + if (metadata.SizeBytes.HasValue) + { + metrics.Add(new ProfilingAnalysisMetric( + "sizeBytes", + "Size", + FormatBytes(metadata.SizeBytes.Value), + metadata.SizeBytes.Value, + "bytes")); + } + + if (metadata.Properties?.TryGetValue("targetPlatform", out var targetPlatform) == true) + { + metrics.Add(new ProfilingAnalysisMetric("targetPlatform", "Platform", targetPlatform)); + } + + if (metadata.Properties?.TryGetValue("scenario", out var scenario) == true) + { + metrics.Add(new ProfilingAnalysisMetric("scenario", "Scenario", scenario)); + } + + if (metadata.Properties?.TryGetValue("category", out var category) == true) + { + metrics.Add(new ProfilingAnalysisMetric("category", "Category", category)); + } + + return metrics; + } + + private static bool LooksLikeSpeedscope(JsonElement root, ProfilingArtifactMetadata metadata) + { + if (metadata.Kind == ProfilingArtifactKind.Trace) + { + return true; + } + + return TryGetProperty(root, "$schema", out var schemaElement) && + schemaElement.ValueKind == JsonValueKind.String && + schemaElement.GetString()?.Contains("speedscope", StringComparison.OrdinalIgnoreCase) == true; + } + + private static bool IsJsonArtifact(ProfilingArtifactMetadata metadata, string artifactPath) + => metadata.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) || + artifactPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase); + + private static bool IsLogArtifact(ProfilingArtifactMetadata metadata, string artifactPath) + => metadata.Kind == ProfilingArtifactKind.Logs || + metadata.ContentType.Contains("text", StringComparison.OrdinalIgnoreCase) || + artifactPath.EndsWith(".log", StringComparison.OrdinalIgnoreCase) || + artifactPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase); + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out value)) + { + return true; + } + + value = default; + return false; + } + + private static string? GetString(JsonElement element, string propertyName) + => TryGetProperty(element, propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static double? TryGetDouble(JsonElement element, string propertyName) + => TryGetProperty(element, propertyName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var result) + ? result + : null; + + private static int? TryGetInt32(JsonElement element, string propertyName) + => TryGetProperty(element, propertyName, out var value) ? TryGetInt32(value) : null; + + private static int? TryGetInt32(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var value)) + { + return value; + } + + return null; + } + + private static ParsedSpeedscopeFrame ParseFrame(JsonElement frame) + { + var name = GetString(frame, "name"); + return new ParsedSpeedscopeFrame( + string.IsNullOrWhiteSpace(name) ? "Unknown frame" : name, + GetString(frame, "file"), + TryGetInt32(frame, "line")); + } + + private static string FormatReferenceValue(double value, SpeedscopeAggregate aggregate) + { + return aggregate.ReferenceMode switch + { + SpeedscopeReferenceMode.DurationMs => $"{value:0.##} ms", + SpeedscopeReferenceMode.SampleWeight => $"{value:0.#} weighted samples", + _ => $"{value:0.##}" + }; + } + + private static string FormatDurationSummary(double durationMs) + => durationMs > 0 ? $"{durationMs:0.##} ms" : "an unknown duration"; + + private static bool CanConvertToMilliseconds(string? unit) + => NormalizeUnit(unit) is "nanoseconds" or "microseconds" or "milliseconds" or "seconds"; + + private static double ConvertToMilliseconds(double value, string? unit) + { + return NormalizeUnit(unit) switch + { + "nanoseconds" => value / 1_000_000d, + "microseconds" => value / 1_000d, + "milliseconds" => value, + "seconds" => value * 1_000d, + _ => value + }; + } + + private static string NormalizeUnit(string? unit) + { + return unit?.Trim().ToLowerInvariant() switch + { + "ns" => "nanoseconds", + "nanosecond" => "nanoseconds", + "nanoseconds" => "nanoseconds", + "us" => "microseconds", + "microsecond" => "microseconds", + "microseconds" => "microseconds", + "ms" => "milliseconds", + "millisecond" => "milliseconds", + "milliseconds" => "milliseconds", + "s" => "seconds", + "sec" => "seconds", + "second" => "seconds", + "seconds" => "seconds", + _ => unit?.Trim().ToLowerInvariant() ?? string.Empty + }; + } + + private static bool TryPopMatchingFrame( + Stack<(int FrameIndex, double StartedAt)> stack, + int expectedFrameIndex, + out (int FrameIndex, double StartedAt) openedFrame) + { + if (stack.Count == 0) + { + openedFrame = default; + return false; + } + + if (stack.Peek().FrameIndex == expectedFrameIndex) + { + openedFrame = stack.Pop(); + return true; + } + + var buffer = new Stack<(int FrameIndex, double StartedAt)>(); + while (stack.Count > 0) + { + var current = stack.Pop(); + if (current.FrameIndex == expectedFrameIndex) + { + while (buffer.Count > 0) + { + stack.Push(buffer.Pop()); + } + + openedFrame = current; + return true; + } + + buffer.Push(current); + } + + while (buffer.Count > 0) + { + stack.Push(buffer.Pop()); + } + + openedFrame = default; + return false; + } + + private static string NormalizeLogMessage(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return string.Empty; + } + + var withoutTimestamp = TimestampPrefixRegex.Replace(line, string.Empty); + var withoutHexValues = HexValueRegex.Replace(withoutTimestamp, "0x*"); + var withoutIntegers = IntegerRegex.Replace(withoutHexValues, "#"); + return MultiWhitespaceRegex.Replace(withoutIntegers.Trim(), " "); + } + + private static DateTimeOffset? TryParseLogTimestamp(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return null; + } + + var match = TimestampPrefixRegex.Match(line); + if (!match.Success) + { + return null; + } + + var stamp = match.Groups["stamp"].Value; + return DateTimeOffset.TryParse(stamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed) + ? parsed + : null; + } + + private static bool IsAlertMetric(string key, double value) + { + var normalized = key.Trim().ToLowerInvariant(); + return value > 0 && (normalized.Contains("error", StringComparison.Ordinal) || + normalized.Contains("exception", StringComparison.Ordinal) || + normalized.Contains("warning", StringComparison.Ordinal) || + normalized.Contains("failure", StringComparison.Ordinal)); + } + + private static string ToDisplayLabel(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var builder = new List(value.Length + 4); + for (var i = 0; i < value.Length; i++) + { + var current = value[i]; + if (i > 0 && char.IsUpper(current) && char.IsLower(value[i - 1])) + { + builder.Add(' '); + } + + builder.Add(current == '_' || current == '-' ? ' ' : current); + } + + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(new string(builder.ToArray())); + } + + private static string CreateMetadataSummary(ProfilingArtifactMetadata metadata) + { + return metadata.Kind switch + { + ProfilingArtifactKind.Export => $"{metadata.DisplayName} is available in the artifact library, but Sherpa currently provides metadata-only analysis for binary exports such as GC dumps.", + ProfilingArtifactKind.Screenshot => $"{metadata.DisplayName} is stored in the artifact library and can be used as supporting evidence for a profiling session.", + _ => $"{metadata.DisplayName} is stored in the artifact library and has a portable metadata summary ready for later UI or Copilot analysis." + }; + } + + private static string CreateMetadataNote(ProfilingArtifactMetadata metadata) + { + return metadata.Kind switch + { + ProfilingArtifactKind.Export => "Open the export in a specialized tool for heap or object graph analysis.", + ProfilingArtifactKind.Screenshot => "Screenshots complement trace and log artifacts but do not currently receive deeper profiling analysis.", + _ => "Portable analysis for this artifact kind can be expanded incrementally without changing the artifact library contract." + }; + } + + private static string FormatBytes(long bytes) + { + string[] suffixes = ["B", "KB", "MB", "GB", "TB"]; + var value = (double)bytes; + var index = 0; + + while (value >= 1024 && index < suffixes.Length - 1) + { + value /= 1024; + index++; + } + + return $"{value:0.##} {suffixes[index]}"; + } + + private sealed record ParsedSpeedscopeFrame(string Name, string? File, int? Line); + + private sealed class SpeedscopeAggregate + { + public int ProfileCount { get; set; } + public int SampledProfileCount { get; set; } + public int EventedProfileCount { get; set; } + public int ReferenceEventCount { get; set; } + public bool UsesSampleWeights { get; set; } + public double TotalReferenceWeight { get; set; } + public double TotalDurationMs { get; set; } + public SpeedscopeReferenceMode ReferenceMode { get; private set; } + public Dictionary InclusiveWeights { get; } = new(); + public HashSet ObservedFrames { get; } = new(); + public List Notes { get; } = new(); + + public void SetReferenceMode(SpeedscopeReferenceMode mode) + { + if (ReferenceMode == SpeedscopeReferenceMode.None) + { + ReferenceMode = mode; + return; + } + + if (ReferenceMode != mode) + { + ReferenceMode = SpeedscopeReferenceMode.Mixed; + } + } + } + + private enum SpeedscopeReferenceMode + { + None, + DurationMs, + SampleWeight, + Mixed + } +} diff --git a/src/MauiSherpa.Core/Services/ProfilingArtifactLibraryService.cs b/src/MauiSherpa.Core/Services/ProfilingArtifactLibraryService.cs new file mode 100644 index 00000000..1fdaaef6 --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingArtifactLibraryService.cs @@ -0,0 +1,371 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public class ProfilingArtifactLibraryService : IProfilingArtifactLibraryService +{ + private readonly IEncryptedSettingsService _settingsService; + private readonly ILoggingService _loggingService; + private readonly string _artifactLibraryRoot; + private readonly SemaphoreSlim _gate = new(1, 1); + + public event Action? OnArtifactsChanged; + + public ProfilingArtifactLibraryService( + IEncryptedSettingsService settingsService, + ILoggingService loggingService) + : this( + settingsService, + loggingService, + Path.Combine(AppDataPath.GetAppDataDirectory(), "profiling-artifacts")) + { + } + + internal ProfilingArtifactLibraryService( + IEncryptedSettingsService settingsService, + ILoggingService loggingService, + string artifactLibraryRoot) + { + _settingsService = settingsService; + _loggingService = loggingService; + _artifactLibraryRoot = Path.GetFullPath(artifactLibraryRoot); + } + + public async Task> GetArtifactsAsync( + ProfilingArtifactLibraryQuery? query = null, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var settings = await _settingsService.GetSettingsAsync(); + IEnumerable artifacts = settings.ProfilingArtifacts; + + if (!string.IsNullOrWhiteSpace(query?.SessionId)) + { + artifacts = artifacts.Where(a => + a.Metadata.SessionId.Equals(query.SessionId, StringComparison.OrdinalIgnoreCase)); + } + + if (query?.Kind is ProfilingArtifactKind kind) + { + artifacts = artifacts.Where(a => a.Metadata.Kind == kind); + } + + if (query is { IncludeMissing: false }) + { + artifacts = artifacts.Where(a => + { + var path = ResolveArtifactPath(a); + return !string.IsNullOrWhiteSpace(path) && File.Exists(path); + }); + } + + return artifacts + .OrderByDescending(a => a.UpdatedAt) + .ThenByDescending(a => a.Metadata.CreatedAt) + .ToArray(); + } + + public async Task GetArtifactAsync(string artifactId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ValidateRequiredText(artifactId, nameof(artifactId)); + + var settings = await _settingsService.GetSettingsAsync(); + return settings.ProfilingArtifacts.FirstOrDefault(a => + a.Metadata.Id.Equals(artifactId, StringComparison.OrdinalIgnoreCase)); + } + + public async Task SaveArtifactAsync( + ProfilingArtifactLibrarySaveRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ValidateMetadata(request.Metadata); + + await _gate.WaitAsync(ct); + try + { + ct.ThrowIfCancellationRequested(); + + var storage = await PrepareStorageAsync(request, ct); + var now = DateTimeOffset.UtcNow; + var metadata = request.Metadata with + { + RelativePath = storage.StoredPath, + SizeBytes = storage.SizeBytes ?? request.Metadata.SizeBytes, + CreatedAt = request.Metadata.CreatedAt == default ? now : request.Metadata.CreatedAt + }; + + ProfilingArtifactLibraryEntry? savedEntry = null; + await _settingsService.UpdateSettingsAsync(settings => + { + var artifacts = settings.ProfilingArtifacts.ToList(); + var existingIndex = artifacts.FindIndex(a => + a.Metadata.Id.Equals(metadata.Id, StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + var existing = artifacts[existingIndex]; + savedEntry = existing with + { + Metadata = metadata, + IsManagedPath = storage.IsManagedPath, + SourcePath = storage.SourcePath ?? existing.SourcePath, + UpdatedAt = now + }; + artifacts[existingIndex] = savedEntry; + } + else + { + savedEntry = new ProfilingArtifactLibraryEntry( + Metadata: metadata, + IsManagedPath: storage.IsManagedPath, + SourcePath: storage.SourcePath, + AddedAt: now, + UpdatedAt: now); + artifacts.Add(savedEntry); + } + + return settings with { ProfilingArtifacts = artifacts }; + }); + + OnArtifactsChanged?.Invoke(); + return savedEntry!; + } + catch (Exception ex) + { + _loggingService.LogError($"Failed to save profiling artifact '{request.Metadata.Id}': {ex.Message}", ex); + throw; + } + finally + { + _gate.Release(); + } + } + + public async Task DeleteArtifactAsync(string artifactId, bool deleteFile = false, CancellationToken ct = default) + { + ValidateRequiredText(artifactId, nameof(artifactId)); + + await _gate.WaitAsync(ct); + try + { + ct.ThrowIfCancellationRequested(); + + var entry = await GetArtifactAsync(artifactId, ct); + if (entry is null) + { + return; + } + + await _settingsService.UpdateSettingsAsync(settings => settings with + { + ProfilingArtifacts = settings.ProfilingArtifacts + .Where(a => !a.Metadata.Id.Equals(artifactId, StringComparison.OrdinalIgnoreCase)) + .ToList() + }); + + if (deleteFile) + { + var path = ResolveArtifactPath(entry); + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + { + File.Delete(path); + DeleteEmptyManagedDirectories(path, entry.IsManagedPath); + } + } + + OnArtifactsChanged?.Invoke(); + } + catch (Exception ex) + { + _loggingService.LogError($"Failed to delete profiling artifact '{artifactId}': {ex.Message}", ex); + throw; + } + finally + { + _gate.Release(); + } + } + + public async Task GetArtifactPathAsync(string artifactId, CancellationToken ct = default) + { + var artifact = await GetArtifactAsync(artifactId, ct); + return artifact is null ? null : ResolveArtifactPath(artifact); + } + + public string GetDefaultArtifactDirectory(string sessionId) + { + ValidateRequiredText(sessionId, nameof(sessionId)); + return Path.Combine(_artifactLibraryRoot, SanitizePathSegment(sessionId)); + } + + private async Task PrepareStorageAsync( + ProfilingArtifactLibrarySaveRequest request, + CancellationToken ct) + { + var candidatePath = request.ArtifactPath ?? request.Metadata.RelativePath; + var normalizedCandidatePath = string.IsNullOrWhiteSpace(candidatePath) + ? null + : Path.GetFullPath(candidatePath.Trim()); + + if (request.CopyToLibrary) + { + if (string.IsNullOrWhiteSpace(normalizedCandidatePath)) + { + throw new InvalidOperationException("A source path is required when copying a profiling artifact into the library."); + } + + if (!File.Exists(normalizedCandidatePath)) + { + throw new FileNotFoundException("The profiling artifact source file could not be found.", normalizedCandidatePath); + } + + var managedRelativePath = BuildManagedRelativePath(request.Metadata); + var managedAbsolutePath = Path.Combine(_artifactLibraryRoot, managedRelativePath); + var managedDirectory = Path.GetDirectoryName(managedAbsolutePath); + if (!string.IsNullOrWhiteSpace(managedDirectory)) + { + Directory.CreateDirectory(managedDirectory); + } + + if (!string.Equals( + Path.GetFullPath(normalizedCandidatePath), + Path.GetFullPath(managedAbsolutePath), + GetPathComparison())) + { + File.Copy(normalizedCandidatePath, managedAbsolutePath, overwrite: true); + } + + ct.ThrowIfCancellationRequested(); + return new ResolvedArtifactStorage( + managedRelativePath, + IsManagedPath: true, + SourcePath: normalizedCandidatePath, + SizeBytes: new FileInfo(managedAbsolutePath).Length); + } + + if (string.IsNullOrWhiteSpace(normalizedCandidatePath)) + { + return new ResolvedArtifactStorage( + BuildManagedRelativePath(request.Metadata), + IsManagedPath: true, + SourcePath: null, + SizeBytes: null); + } + + if (IsPathUnderRoot(normalizedCandidatePath, _artifactLibraryRoot)) + { + return new ResolvedArtifactStorage( + Path.GetRelativePath(_artifactLibraryRoot, normalizedCandidatePath), + IsManagedPath: true, + SourcePath: null, + SizeBytes: TryGetFileSize(normalizedCandidatePath)); + } + + return new ResolvedArtifactStorage( + normalizedCandidatePath, + IsManagedPath: false, + SourcePath: null, + SizeBytes: TryGetFileSize(normalizedCandidatePath)); + } + + private string? ResolveArtifactPath(ProfilingArtifactLibraryEntry entry) + { + var storedPath = entry.Metadata.RelativePath; + if (string.IsNullOrWhiteSpace(storedPath)) + { + return null; + } + + return entry.IsManagedPath + ? Path.GetFullPath(Path.Combine(_artifactLibraryRoot, storedPath)) + : Path.GetFullPath(storedPath); + } + + private void DeleteEmptyManagedDirectories(string deletedPath, bool isManagedPath) + { + if (!isManagedPath) + { + return; + } + + var current = Path.GetDirectoryName(Path.GetFullPath(deletedPath)); + while (!string.IsNullOrWhiteSpace(current) && + IsPathUnderRoot(current, _artifactLibraryRoot) && + !string.Equals(current, _artifactLibraryRoot, GetPathComparison())) + { + if (Directory.EnumerateFileSystemEntries(current).Any()) + { + return; + } + + Directory.Delete(current); + current = Path.GetDirectoryName(current); + } + } + + private static string BuildManagedRelativePath(ProfilingArtifactMetadata metadata) + { + var sessionSegment = SanitizePathSegment(metadata.SessionId); + var idSegment = SanitizePathSegment(metadata.Id); + var fileName = Path.GetFileName(string.IsNullOrWhiteSpace(metadata.FileName) ? $"{idSegment}.bin" : metadata.FileName); + var safeFileName = SanitizeFileName(fileName); + return Path.Combine(sessionSegment, $"{idSegment}-{safeFileName}"); + } + + private static string SanitizePathSegment(string value) + { + ValidateRequiredText(value, nameof(value)); + return new string(value.Trim().Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch).ToArray()); + } + + private static string SanitizeFileName(string value) + { + var fileName = new string(value.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch).ToArray()); + return string.IsNullOrWhiteSpace(fileName) ? "artifact.bin" : fileName; + } + + private static long? TryGetFileSize(string path) => File.Exists(path) ? new FileInfo(path).Length : null; + + private static bool IsPathUnderRoot(string path, string rootPath) + { + var normalizedPath = EnsureTrailingSeparator(Path.GetFullPath(path)); + var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootPath)); + return normalizedPath.StartsWith(normalizedRoot, GetPathComparison()); + } + + private static string EnsureTrailingSeparator(string path) => + path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar) + ? path + : path + Path.DirectorySeparatorChar; + + private static StringComparison GetPathComparison() => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + private static void ValidateMetadata(ProfilingArtifactMetadata metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + ValidateRequiredText(metadata.Id, nameof(metadata.Id)); + ValidateRequiredText(metadata.SessionId, nameof(metadata.SessionId)); + ValidateRequiredText(metadata.DisplayName, nameof(metadata.DisplayName)); + ValidateRequiredText(metadata.FileName, nameof(metadata.FileName)); + ValidateRequiredText(metadata.ContentType, nameof(metadata.ContentType)); + } + + private static void ValidateRequiredText(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("A value is required.", paramName); + } + } + + private sealed record ResolvedArtifactStorage( + string StoredPath, + bool IsManagedPath, + string? SourcePath, + long? SizeBytes); +} diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs new file mode 100644 index 00000000..a361d7f6 --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -0,0 +1,760 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public class ProfilingCaptureOrchestrationService : IProfilingCaptureOrchestrationService +{ + private const string ProcessIdToken = "{{PROCESS_ID}}"; + private static readonly IReadOnlySet TraceCaptureKinds = new HashSet + { + ProfilingCaptureKind.Startup, + ProfilingCaptureKind.Cpu, + ProfilingCaptureKind.Network, + ProfilingCaptureKind.Rendering, + ProfilingCaptureKind.Energy, + ProfilingCaptureKind.SystemTrace + }; + + private readonly IProfilingCatalogService _profilingCatalogService; + private readonly IProfilingPrerequisitesService _profilingPrerequisitesService; + private readonly IDeviceMonitorService _deviceMonitorService; + private readonly IPlatformService _platformService; + private readonly IAndroidSdkSettingsService _androidSdkSettingsService; + private readonly ILoggingService _loggingService; + + public ProfilingCaptureOrchestrationService( + IProfilingCatalogService profilingCatalogService, + IProfilingPrerequisitesService profilingPrerequisitesService, + IDeviceMonitorService deviceMonitorService, + IPlatformService platformService, + IAndroidSdkSettingsService androidSdkSettingsService, + ILoggingService loggingService) + { + _profilingCatalogService = profilingCatalogService; + _profilingPrerequisitesService = profilingPrerequisitesService; + _deviceMonitorService = deviceMonitorService; + _platformService = platformService; + _androidSdkSettingsService = androidSdkSettingsService; + _loggingService = loggingService; + } + + public async Task PlanCaptureAsync( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions? options = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(definition); + + var normalizedOptions = NormalizeOptions(definition, options); + var targetFramework = ResolveTargetFramework(definition.Target.Platform, normalizedOptions.TargetFramework); + var workingDirectory = ResolveWorkingDirectory(normalizedOptions); + var capabilities = await _profilingCatalogService.GetCapabilitiesAsync(definition.Target.Platform, ct); + var definitionValidation = _profilingCatalogService.ValidateSessionDefinition(definition, capabilities); + var prerequisites = await _profilingPrerequisitesService.GetPrerequisitesAsync( + definition.Target.Platform, + definition.CaptureKinds, + workingDirectory, + ct); + + var errors = new List(definitionValidation.Errors); + var warnings = new List(); + var commands = new List(); + var runtimeBindings = new List(); + var expectedArtifacts = new List(); + var metadata = CreatePlanMetadata(definition, normalizedOptions, targetFramework); + + AppendPrerequisiteFindings(prerequisites, errors, warnings); + + if (normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Launch && + string.IsNullOrWhiteSpace(normalizedOptions.ProjectPath)) + { + errors.Add("A project path is required to plan build and launch steps."); + } + + var isTargetCurrentlyAvailable = IsTargetCurrentlyAvailable(definition.Target); + if (!isTargetCurrentlyAvailable && RequiresConnectedTarget(definition.Target)) + { + warnings.Add($"Target '{definition.Target.Identifier}' is not currently present in the connected device snapshot."); + } + + if (normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Attach && + !capabilities.SupportsAttachToProcess) + { + errors.Add($"{capabilities.DisplayName} capabilities do not support attach flows."); + } + + var diagnostics = BuildDiagnosticsConfiguration(definition.Target, normalizedOptions, _platformService.IsWindows); + var traceArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, $"{definition.Id}-trace.speedscope.json"); + var gcdumpArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, $"{definition.Id}-memory.gcdump"); + var logsArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, $"{definition.Id}-logs.txt"); + + var androidSdkPath = definition.Target.Platform == ProfilingTargetPlatform.Android + ? await TryGetAndroidSdkPathAsync() + : null; + + // Modern dotnet-trace/dotnet-gcdump support --dsrouter natively, so we no longer + // need a standalone dotnet-dsrouter process. Compute the platform arg once here. + var dsrouterPlatformArg = GetDsRouterPlatformArg(definition.Target); + + var hasTraceCapture = definition.CaptureKinds.Any(kind => TraceCaptureKinds.Contains(kind)); + var hasMemoryCapture = definition.CaptureKinds.Contains(ProfilingCaptureKind.Memory); + var hasLogCapture = definition.CaptureKinds.Contains(ProfilingCaptureKind.Logs); + var preLaunchCaptureSteps = new List(); + var postLaunchCaptureSteps = new List(); + + if (hasTraceCapture) + { + var (traceStep, traceArtifact) = CreateTraceCaptureStep( + definition, + normalizedOptions, + dsrouterPlatformArg, + traceArtifactPath, + runtimeBindings); + + if (dsrouterPlatformArg is not null && + normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Launch && + normalizedOptions.SuspendAtStartup) + { + preLaunchCaptureSteps.Add(traceStep); + } + else + { + postLaunchCaptureSteps.Add(traceStep); + } + + expectedArtifacts.Add(traceArtifact); + } + + if (normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Launch) + { + commands.AddRange(preLaunchCaptureSteps); + commands.Add(CreateLaunchStep(definition, normalizedOptions, targetFramework, workingDirectory, diagnostics)); + } + + if (dsrouterPlatformArg is null && + normalizedOptions.ProcessId is null && + (hasTraceCapture || hasMemoryCapture)) + { + commands.Add(CreateProcessDiscoveryStep(definition, normalizedOptions)); + if (runtimeBindings.All(binding => binding.Token != ProcessIdToken)) + { + runtimeBindings.Add(new ProfilingRuntimeBinding( + ProcessIdToken, + "Resolve the local desktop process id after the app is running.", + ExampleValue: "12345")); + } + } + + if (hasMemoryCapture) + { + var (memoryStep, memoryArtifact) = CreateMemoryCaptureStep( + definition, + normalizedOptions, + dsrouterPlatformArg, + gcdumpArtifactPath, + runtimeBindings); + + postLaunchCaptureSteps.Add(memoryStep); + expectedArtifacts.Add(memoryArtifact); + } + + if (hasLogCapture) + { + var logStep = CreateLogCaptureStep(definition, normalizedOptions, logsArtifactPath, androidSdkPath); + if (logStep is not null) + { + postLaunchCaptureSteps.Add(logStep); + expectedArtifacts.Add(new ProfilingArtifactMetadata( + Id: $"{definition.Id}-logs", + SessionId: definition.Id, + Kind: ProfilingArtifactKind.Logs, + DisplayName: "Streaming logs", + FileName: Path.GetFileName(logsArtifactPath), + RelativePath: logsArtifactPath, + ContentType: "text/plain", + CreatedAt: DateTimeOffset.UtcNow, + Properties: CreateArtifactProperties(definition, "logs"))); + } + else + { + warnings.Add($"Logs capture planning is not yet modeled for {capabilities.DisplayName} {definition.Target.Kind} targets."); + } + } + + commands.AddRange(postLaunchCaptureSteps); + + var validation = new ProfilingPlanValidation( + Errors: errors + .Where(error => !string.IsNullOrWhiteSpace(error)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(), + Warnings: warnings + .Where(warning => !string.IsNullOrWhiteSpace(warning)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray()); + + if (!validation.IsValid) + { + _loggingService.LogDebug( + $"Profiling capture plan for session '{definition.Id}' contains validation issues: {string.Join(" | ", validation.Errors)}"); + } + + return new ProfilingCapturePlan( + definition, + capabilities, + normalizedOptions, + _platformService.PlatformName, + targetFramework, + normalizedOptions.OutputDirectory!, + workingDirectory, + isTargetCurrentlyAvailable, + diagnostics, + prerequisites, + validation, + runtimeBindings.ToArray(), + commands.ToArray(), + expectedArtifacts.ToArray(), + metadata); + } + + private static ProfilingCapturePlanOptions NormalizeOptions( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions? options) + { + var normalized = options ?? new ProfilingCapturePlanOptions(); + var configuration = string.IsNullOrWhiteSpace(normalized.Configuration) ? "Release" : normalized.Configuration.Trim(); + var projectPath = string.IsNullOrWhiteSpace(normalized.ProjectPath) ? null : normalized.ProjectPath.Trim(); + var workingDirectory = string.IsNullOrWhiteSpace(normalized.WorkingDirectory) ? null : normalized.WorkingDirectory.Trim(); + var effectiveWorkingDirectory = workingDirectory ?? (string.IsNullOrWhiteSpace(projectPath) ? null : Path.GetDirectoryName(projectPath)); + var outputDirectory = string.IsNullOrWhiteSpace(normalized.OutputDirectory) + ? Path.Combine("artifacts", "profiling", definition.Id) + : normalized.OutputDirectory.Trim(); + var additionalBuildProperties = normalized.AdditionalBuildProperties is null + ? null + : new Dictionary(normalized.AdditionalBuildProperties, StringComparer.OrdinalIgnoreCase); + + return normalized with + { + ProjectPath = projectPath, + Configuration = configuration, + WorkingDirectory = effectiveWorkingDirectory, + OutputDirectory = outputDirectory, + AdditionalBuildProperties = additionalBuildProperties + }; + } + + private static string ResolveTargetFramework(ProfilingTargetPlatform platform, string? targetFrameworkOverride) => + string.IsNullOrWhiteSpace(targetFrameworkOverride) + ? platform switch + { + ProfilingTargetPlatform.Android => "net10.0-android", + ProfilingTargetPlatform.iOS => "net10.0-ios", + ProfilingTargetPlatform.MacCatalyst => "net10.0-maccatalyst", + ProfilingTargetPlatform.MacOS => "net10.0-macos", + ProfilingTargetPlatform.Windows => "net10.0-windows10.0.19041.0", + _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unsupported profiling platform.") + } + : targetFrameworkOverride.Trim(); + + private static string? ResolveWorkingDirectory(ProfilingCapturePlanOptions options) + { + if (!string.IsNullOrWhiteSpace(options.WorkingDirectory)) + return options.WorkingDirectory; + + return string.IsNullOrWhiteSpace(options.ProjectPath) + ? null + : Path.GetDirectoryName(options.ProjectPath); + } + + private static IReadOnlyDictionary CreatePlanMetadata( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions options, + string targetFramework) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sessionId"] = definition.Id, + ["targetPlatform"] = definition.Target.Platform.ToString(), + ["targetKind"] = definition.Target.Kind.ToString(), + ["targetIdentifier"] = definition.Target.Identifier, + ["configuration"] = options.Configuration, + ["launchMode"] = options.LaunchMode.ToString(), + ["targetFramework"] = targetFramework, + ["outputDirectory"] = options.OutputDirectory ?? string.Empty + }; + + if (!string.IsNullOrWhiteSpace(options.ProjectPath)) + metadata["projectPath"] = options.ProjectPath; + + return metadata; + } + + private static void AppendPrerequisiteFindings( + ProfilingPrerequisiteReport prerequisites, + List errors, + List warnings) + { + foreach (var check in prerequisites.Checks.Where(check => check.IsRequired && check.Status == DependencyStatusType.Error)) + { + errors.Add(check.Message ?? $"{check.Name} is required for profiling orchestration."); + } + + foreach (var check in prerequisites.Checks.Where(check => check.Status == DependencyStatusType.Warning)) + { + warnings.Add(check.Message ?? $"{check.Name} requires attention before profiling."); + } + } + + private bool IsTargetCurrentlyAvailable(ProfilingTarget target) + { + var snapshot = _deviceMonitorService.Current; + + return target.Platform switch + { + ProfilingTargetPlatform.Android when target.Kind == ProfilingTargetKind.PhysicalDevice + => snapshot.AndroidDevices.Any(device => string.Equals(device.Serial, target.Identifier, StringComparison.OrdinalIgnoreCase)), + ProfilingTargetPlatform.Android when target.Kind == ProfilingTargetKind.Emulator + => snapshot.AndroidEmulators.Any(device => string.Equals(device.Serial, target.Identifier, StringComparison.OrdinalIgnoreCase)), + ProfilingTargetPlatform.iOS when target.Kind == ProfilingTargetKind.PhysicalDevice + => snapshot.ApplePhysicalDevices.Any(device => string.Equals(device.Identifier, target.Identifier, StringComparison.OrdinalIgnoreCase)), + ProfilingTargetPlatform.iOS when target.Kind == ProfilingTargetKind.Simulator + => snapshot.BootedSimulators.Any(device => string.Equals(device.Identifier, target.Identifier, StringComparison.OrdinalIgnoreCase)), + _ => true + }; + } + + private static bool RequiresConnectedTarget(ProfilingTarget target) => + (target.Platform == ProfilingTargetPlatform.Android && + target.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Emulator) + || (target.Platform == ProfilingTargetPlatform.iOS && + target.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Simulator); + + private static ProfilingDiagnosticConfiguration? BuildDiagnosticsConfiguration( + ProfilingTarget target, + ProfilingCapturePlanOptions options, + bool isWindowsHost) + { + if (target.Platform is not ProfilingTargetPlatform.Android and not ProfilingTargetPlatform.iOS) + return null; + + var ipcAddress = isWindowsHost + ? $@"\\.\pipe\maui-sherpa-profile-{Guid.NewGuid():N}" + : Path.Combine(Path.GetTempPath(), $"maui-sherpa-profile-{Guid.NewGuid():N}.sock"); + var tcpEndpoint = $"127.0.0.1:{options.DiagnosticPort}"; + + return target.Platform switch + { + ProfilingTargetPlatform.Android => new ProfilingDiagnosticConfiguration( + Address: target.Kind == ProfilingTargetKind.Emulator ? "10.0.2.2" : "127.0.0.1", + Port: options.DiagnosticPort, + ListenMode: ProfilingDiagnosticListenMode.Connect, + SuspendOnStartup: options.SuspendAtStartup, + RequiresDsRouter: true, + DsRouterMode: ProfilingDsRouterMode.ServerServer, + DsRouterPortForwardPlatform: "Android", + IpcAddress: ipcAddress, + TcpEndpoint: tcpEndpoint), + ProfilingTargetPlatform.iOS => new ProfilingDiagnosticConfiguration( + Address: "127.0.0.1", + Port: options.DiagnosticPort, + ListenMode: ProfilingDiagnosticListenMode.Listen, + SuspendOnStartup: options.SuspendAtStartup, + RequiresDsRouter: true, + DsRouterMode: ProfilingDsRouterMode.ServerClient, + DsRouterPortForwardPlatform: target.Kind == ProfilingTargetKind.PhysicalDevice ? "iOS" : null, + IpcAddress: ipcAddress, + TcpEndpoint: tcpEndpoint), + _ => null + }; + } + + /// + /// Creates a standalone dotnet-dsrouter process step. Kept as a fallback for environments + /// where the integrated --dsrouter flag in dotnet-trace/dotnet-gcdump is not available. + /// In the normal flow, --dsrouter is passed directly to the capture tools instead. + /// + private static ProfilingCommandStep CreateDsRouterStep( + ProfilingSessionDefinition definition, + ProfilingDiagnosticConfiguration diagnostics, + ProfilingCapturePlanOptions options, + string? androidSdkPath) + { + var arguments = new List + { + diagnostics.DsRouterMode == ProfilingDsRouterMode.ServerServer ? "server-server" : "server-client", + "-ipcs", + diagnostics.IpcAddress, + diagnostics.DsRouterMode == ProfilingDsRouterMode.ServerServer ? "-tcps" : "-tcpc", + diagnostics.TcpEndpoint, + "-rt", + Math.Max(30, (int)Math.Ceiling((definition.Duration ?? TimeSpan.FromMinutes(5)).TotalSeconds)).ToString() + }; + + if (!string.IsNullOrWhiteSpace(diagnostics.DsRouterPortForwardPlatform)) + { + arguments.Add("--forward-port"); + arguments.Add(diagnostics.DsRouterPortForwardPlatform); + } + + Dictionary? environment = null; + if (definition.Target.Platform == ProfilingTargetPlatform.Android && !string.IsNullOrWhiteSpace(androidSdkPath)) + { + environment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ANDROID_HOME"] = androidSdkPath + }; + } + + return new ProfilingCommandStep( + Id: "start-dsrouter", + Kind: ProfilingCommandStepKind.Prepare, + DisplayName: "Start diagnostics router", + Description: "Start dotnet-dsrouter so local diagnostic tools can talk to the remote mobile runtime.", + Command: "dotnet-dsrouter", + Arguments: arguments, + WorkingDirectory: options.WorkingDirectory, + Environment: environment, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "dotnet-dsrouter", + ["mode"] = diagnostics.DsRouterMode.ToString(), + ["ipcAddress"] = diagnostics.IpcAddress, + ["tcpEndpoint"] = diagnostics.TcpEndpoint, + ["portForward"] = diagnostics.DsRouterPortForwardPlatform ?? string.Empty + }, + IsLongRunning: true, + RequiresManualStop: true); + } + + private static ProfilingCommandStep CreateLaunchStep( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions options, + string targetFramework, + string? workingDirectory, + ProfilingDiagnosticConfiguration? diagnostics) + { + var arguments = new List + { + "build" + }; + + if (!string.IsNullOrWhiteSpace(options.ProjectPath)) + arguments.Add(options.ProjectPath); + + arguments.Add("-t:Run"); + arguments.Add("-c"); + arguments.Add(options.Configuration); + arguments.Add("-f"); + arguments.Add(targetFramework); + + if (diagnostics is not null) + { + arguments.Add("-p:EnableDiagnostics=true"); + arguments.Add($"-p:DiagnosticAddress={diagnostics.Address}"); + arguments.Add($"-p:DiagnosticPort={diagnostics.Port}"); + arguments.Add($"-p:DiagnosticSuspend={diagnostics.SuspendOnStartup.ToString().ToLowerInvariant()}"); + arguments.Add($"-p:DiagnosticListenMode={diagnostics.ListenMode.ToString().ToLowerInvariant()}"); + } + + if (options.AdditionalBuildProperties is not null) + { + foreach (var buildProperty in options.AdditionalBuildProperties.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) + { + arguments.Add($"-p:{buildProperty.Key}={buildProperty.Value}"); + } + } + + return new ProfilingCommandStep( + Id: "build-and-run", + Kind: ProfilingCommandStepKind.Launch, + DisplayName: "Build and run target app", + Description: $"Build and launch {definition.Target.DisplayName} using {targetFramework}.", + Command: "dotnet", + Arguments: arguments, + WorkingDirectory: workingDirectory, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "dotnet", + ["targetFramework"] = targetFramework, + ["configuration"] = options.Configuration, + ["targetIdentifier"] = definition.Target.Identifier, + ["launchMode"] = options.LaunchMode.ToString() + }, + IsLongRunning: true, + RequiresManualStop: definition.Target.Platform is ProfilingTargetPlatform.MacCatalyst or ProfilingTargetPlatform.MacOS or ProfilingTargetPlatform.Windows, + CanRunParallel: true, + StopTrigger: ProfilingStopTrigger.OnPipelineStop); + } + + private static ProfilingCommandStep CreateProcessDiscoveryStep( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions options) + { + return new ProfilingCommandStep( + Id: "discover-process-id", + Kind: ProfilingCommandStepKind.DiscoverProcess, + DisplayName: "Discover target process id", + Description: $"List local .NET processes and bind {ProcessIdToken} to the running {definition.Target.DisplayName} process before attaching.", + Command: "dotnet-trace", + Arguments: ["ps"], + WorkingDirectory: options.WorkingDirectory, + RequiredRuntimeBindings: [ProcessIdToken], + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "dotnet-trace", + ["runtimeBinding"] = ProcessIdToken + }); + } + + private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) CreateTraceCaptureStep( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions options, + string? dsrouterPlatformArg, + string traceArtifactPath, + List runtimeBindings) + { + var traceKinds = definition.CaptureKinds + .Where(kind => TraceCaptureKinds.Contains(kind)) + .Select(kind => kind.ToString()) + .ToArray(); + var arguments = new List + { + "collect" + }; + + if (dsrouterPlatformArg is not null) + { + arguments.Add("--dsrouter"); + arguments.Add(dsrouterPlatformArg); + } + else + { + arguments.Add("--process-id"); + arguments.Add(options.ProcessId?.ToString() ?? ProcessIdToken); + if (options.ProcessId is null) + { + runtimeBindings.Add(new ProfilingRuntimeBinding( + ProcessIdToken, + "Resolve the process id before starting dotnet-trace.", + ExampleValue: "12345")); + } + } + + arguments.Add("--format"); + arguments.Add("speedscope"); + arguments.Add("--output"); + arguments.Add(traceArtifactPath); + + return ( + new ProfilingCommandStep( + Id: "capture-trace", + Kind: ProfilingCommandStepKind.Capture, + DisplayName: "Collect trace", + Description: $"Collect a speedscope trace for {string.Join(", ", traceKinds)} captures.", + Command: "dotnet-trace", + Arguments: arguments, + WorkingDirectory: options.WorkingDirectory, + DependsOn: dsrouterPlatformArg is null && options.ProcessId is null ? ["discover-process-id"] : null, + RequiredRuntimeBindings: dsrouterPlatformArg is null && options.ProcessId is null ? [ProcessIdToken] : null, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "dotnet-trace", + ["captureKinds"] = string.Join(",", traceKinds), + ["output"] = traceArtifactPath + }, + IsLongRunning: true, + RequiresManualStop: true, + CanRunParallel: true, + StopTrigger: ProfilingStopTrigger.ManualStop), + new ProfilingArtifactMetadata( + Id: $"{definition.Id}-trace", + SessionId: definition.Id, + Kind: ProfilingArtifactKind.Trace, + DisplayName: "Trace capture", + FileName: Path.GetFileName(traceArtifactPath), + RelativePath: traceArtifactPath, + ContentType: "application/json", + CreatedAt: DateTimeOffset.UtcNow, + Properties: CreateArtifactProperties(definition, "trace"))); + } + + private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) CreateMemoryCaptureStep( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions options, + string? dsrouterPlatformArg, + string gcdumpArtifactPath, + List runtimeBindings) + { + var arguments = new List + { + "collect" + }; + + if (dsrouterPlatformArg is not null) + { + arguments.Add("--dsrouter"); + arguments.Add(dsrouterPlatformArg); + } + else + { + arguments.Add("--process-id"); + arguments.Add(options.ProcessId?.ToString() ?? ProcessIdToken); + if (options.ProcessId is null && runtimeBindings.All(binding => binding.Token != ProcessIdToken)) + { + runtimeBindings.Add(new ProfilingRuntimeBinding( + ProcessIdToken, + "Resolve the process id before collecting a GC dump.", + ExampleValue: "12345")); + } + } + + arguments.Add("-o"); + arguments.Add(gcdumpArtifactPath); + + return ( + new ProfilingCommandStep( + Id: "capture-memory", + Kind: ProfilingCommandStepKind.CollectArtifacts, + DisplayName: "Collect GC dump", + Description: "Collect a managed memory dump using dotnet-gcdump.", + Command: "dotnet-gcdump", + Arguments: arguments, + WorkingDirectory: options.WorkingDirectory, + DependsOn: dsrouterPlatformArg is null && options.ProcessId is null ? ["discover-process-id"] : null, + RequiredRuntimeBindings: dsrouterPlatformArg is null && options.ProcessId is null ? [ProcessIdToken] : null, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "dotnet-gcdump", + ["output"] = gcdumpArtifactPath + }), + new ProfilingArtifactMetadata( + Id: $"{definition.Id}-memory", + SessionId: definition.Id, + Kind: ProfilingArtifactKind.Export, + DisplayName: "GC dump", + FileName: Path.GetFileName(gcdumpArtifactPath), + RelativePath: gcdumpArtifactPath, + ContentType: "application/octet-stream", + CreatedAt: DateTimeOffset.UtcNow, + Properties: CreateArtifactProperties(definition, "memory"))); + } + + private static ProfilingCommandStep? CreateLogCaptureStep( + ProfilingSessionDefinition definition, + ProfilingCapturePlanOptions options, + string logsArtifactPath, + string? androidSdkPath) + { + switch (definition.Target.Platform, definition.Target.Kind) + { + case (ProfilingTargetPlatform.Android, ProfilingTargetKind.PhysicalDevice): + case (ProfilingTargetPlatform.Android, ProfilingTargetKind.Emulator): + Dictionary? environment = null; + if (!string.IsNullOrWhiteSpace(androidSdkPath)) + { + environment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ANDROID_HOME"] = androidSdkPath + }; + } + + return new ProfilingCommandStep( + Id: "capture-logs", + Kind: ProfilingCommandStepKind.Capture, + DisplayName: "Stream Android logs", + Description: $"Stream adb logcat output for {definition.Target.DisplayName}. Redirect output to {logsArtifactPath}.", + Command: "adb", + Arguments: ["-s", definition.Target.Identifier, "logcat", "-v", "threadtime"], + WorkingDirectory: options.WorkingDirectory, + Environment: environment, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "adb", + ["outputHint"] = logsArtifactPath + }, + IsLongRunning: true, + RequiresManualStop: true, + CanRunParallel: true, + StopTrigger: ProfilingStopTrigger.OnPipelineStop); + + case (ProfilingTargetPlatform.iOS, ProfilingTargetKind.Simulator): + return new ProfilingCommandStep( + Id: "capture-logs", + Kind: ProfilingCommandStepKind.Capture, + DisplayName: "Stream simulator logs", + Description: $"Stream simulator logs for {definition.Target.DisplayName}. Redirect output to {logsArtifactPath}.", + Command: "xcrun", + Arguments: ["simctl", "spawn", definition.Target.Identifier, "log", "stream", "--style", "ndjson", "--level", "debug"], + WorkingDirectory: options.WorkingDirectory, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "xcrun", + ["outputHint"] = logsArtifactPath + }, + IsLongRunning: true, + RequiresManualStop: true, + CanRunParallel: true, + StopTrigger: ProfilingStopTrigger.OnPipelineStop); + + case (ProfilingTargetPlatform.iOS, ProfilingTargetKind.PhysicalDevice): + return new ProfilingCommandStep( + Id: "capture-logs", + Kind: ProfilingCommandStepKind.Capture, + DisplayName: "Stream device logs", + Description: $"Stream physical device logs for {definition.Target.DisplayName}. Redirect output to {logsArtifactPath}.", + Command: "idevicesyslog", + Arguments: ["-u", definition.Target.Identifier], + WorkingDirectory: options.WorkingDirectory, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "idevicesyslog", + ["outputHint"] = logsArtifactPath, + ["alternativeTool"] = "pymobiledevice3 syslog live --udid " + }, + IsLongRunning: true, + RequiresManualStop: true, + CanRunParallel: true, + StopTrigger: ProfilingStopTrigger.OnPipelineStop); + + default: + return null; + } + } + + private async Task TryGetAndroidSdkPathAsync() + { + try + { + return await _androidSdkSettingsService.GetEffectiveSdkPathAsync(); + } + catch (Exception ex) + { + _loggingService.LogDebug($"Failed to resolve Android SDK path for profiling orchestration: {ex.Message}"); + return null; + } + } + + private static string? GetDsRouterPlatformArg(ProfilingTarget target) => + (target.Platform, target.Kind) switch + { + (ProfilingTargetPlatform.Android, ProfilingTargetKind.Emulator) => "android-emu", + (ProfilingTargetPlatform.Android, ProfilingTargetKind.PhysicalDevice) => "android", + (ProfilingTargetPlatform.iOS, ProfilingTargetKind.Simulator) => "ios-sim", + (ProfilingTargetPlatform.iOS, ProfilingTargetKind.PhysicalDevice) => "ios", + _ => null + }; + + private static IReadOnlyDictionary CreateArtifactProperties( + ProfilingSessionDefinition definition, + string category) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["targetPlatform"] = definition.Target.Platform.ToString(), + ["targetIdentifier"] = definition.Target.Identifier, + ["scenario"] = definition.Scenario.ToString(), + ["category"] = category + }; + } +} diff --git a/src/MauiSherpa.Core/Services/ProfilingCatalogService.cs b/src/MauiSherpa.Core/Services/ProfilingCatalogService.cs new file mode 100644 index 00000000..29b0139d --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingCatalogService.cs @@ -0,0 +1,218 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public class ProfilingCatalogService : IProfilingCatalogService +{ + private static readonly IReadOnlyDictionary BuiltInScenarios = + new Dictionary + { + [ProfilingScenarioKind.Launch] = new( + ProfilingScenarioKind.Launch, + "Launch & startup", + "Capture cold or warm start behavior with startup, CPU, and memory signals.", + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory], + TimeSpan.FromMinutes(2)), + [ProfilingScenarioKind.Interaction] = new( + ProfilingScenarioKind.Interaction, + "Interaction trace", + "Focus on a bounded interaction such as tapping through a flow or completing a task.", + [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Memory], + TimeSpan.FromMinutes(5), + SupportsContinuousCapture: true), + [ProfilingScenarioKind.Scrolling] = new( + ProfilingScenarioKind.Scrolling, + "Scrolling & rendering", + "Measure rendering smoothness and CPU pressure during scrolling-heavy experiences.", + [ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory], + TimeSpan.FromMinutes(3), + SupportsContinuousCapture: true), + [ProfilingScenarioKind.BackgroundWork] = new( + ProfilingScenarioKind.BackgroundWork, + "Background work", + "Profile sync, notifications, or other longer-running work that happens away from the main UI.", + [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Network, ProfilingCaptureKind.Energy], + TimeSpan.FromMinutes(10), + SupportsContinuousCapture: true), + [ProfilingScenarioKind.MemoryInvestigation] = new( + ProfilingScenarioKind.MemoryInvestigation, + "Memory investigation", + "Use memory-oriented captures to investigate leaks, spikes, and long-lived allocations.", + [ProfilingCaptureKind.Memory, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Logs], + TimeSpan.FromMinutes(15), + SupportsContinuousCapture: true) + }; + + private static readonly IReadOnlyDictionary BuiltInCapabilities = + new Dictionary + { + [ProfilingTargetPlatform.Android] = new( + ProfilingTargetPlatform.Android, + "Android", + [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Emulator], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Screenshot, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], + BuiltInScenarios.Keys.ToArray(), + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: false, + Notes: "Initial Android abstractions assume adb-backed devices and emulators."), + [ProfilingTargetPlatform.iOS] = new( + ProfilingTargetPlatform.iOS, + "iOS", + [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Simulator], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Screenshot, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], + BuiltInScenarios.Keys.ToArray(), + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: true, + Notes: "Initial iOS abstractions cover both physical devices and simulators."), + [ProfilingTargetPlatform.MacCatalyst] = new( + ProfilingTargetPlatform.MacCatalyst, + "Mac Catalyst", + [ProfilingTargetKind.Desktop], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], + BuiltInScenarios.Keys.ToArray(), + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: true, + Notes: "Mac Catalyst is treated as a desktop target with Apple tooling semantics."), + [ProfilingTargetPlatform.MacOS] = new( + ProfilingTargetPlatform.MacOS, + "macOS", + [ProfilingTargetKind.Desktop], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], + BuiltInScenarios.Keys.ToArray(), + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: true, + Notes: "macOS profiling is modeled as a desktop target that can evolve beyond MAUI-specific flows."), + [ProfilingTargetPlatform.Windows] = new( + ProfilingTargetPlatform.Windows, + "Windows", + [ProfilingTargetKind.Desktop], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], + BuiltInScenarios.Keys.ToArray(), + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: false, + Notes: "Windows support is modeled for desktop processes and future tooling adapters.") + }; + + private readonly IReadOnlyDictionary _capabilityProviders; + + public ProfilingCatalogService(IEnumerable capabilityProviders) + { + _capabilityProviders = capabilityProviders + .GroupBy(provider => provider.Platform) + .ToDictionary(group => group.Key, group => group.Last()); + } + + public async Task GetCatalogAsync(CancellationToken ct = default) + { + var platforms = new List(); + + foreach (var platform in Enum.GetValues()) + platforms.Add(await GetCapabilitiesAsync(platform, ct)); + + return new ProfilingCatalog(platforms, BuiltInScenarios.Values.ToArray()); + } + + public async Task GetCapabilitiesAsync(ProfilingTargetPlatform platform, CancellationToken ct = default) + { + if (!BuiltInCapabilities.TryGetValue(platform, out var builtInCapabilities)) + throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unsupported profiling platform."); + + if (_capabilityProviders.TryGetValue(platform, out var provider)) + { + var providerCapabilities = await provider.GetCapabilitiesAsync(ct); + if (providerCapabilities is not null) + return providerCapabilities; + } + + return builtInCapabilities; + } + + public ProfilingSessionDefinition CreateSessionDefinition( + ProfilingTarget target, + ProfilingScenarioKind scenario, + string? name = null, + IReadOnlyList? captureKinds = null, + string? appId = null, + TimeSpan? duration = null, + IReadOnlyDictionary? tags = null) + { + if (!BuiltInScenarios.TryGetValue(scenario, out var scenarioDefinition)) + throw new ArgumentOutOfRangeException(nameof(scenario), scenario, "Unknown profiling scenario."); + + var normalizedName = string.IsNullOrWhiteSpace(name) + ? $"{target.DisplayName} - {scenarioDefinition.DisplayName}" + : name.Trim(); + + var normalizedCaptureKinds = captureKinds is { Count: > 0 } + ? captureKinds.Distinct().ToArray() + : scenarioDefinition.DefaultCaptureKinds.ToArray(); + + return new ProfilingSessionDefinition( + Guid.NewGuid().ToString("N"), + normalizedName, + target, + scenario, + normalizedCaptureKinds, + appId, + duration ?? scenarioDefinition.SuggestedDuration, + tags is null ? new Dictionary() : new Dictionary(tags), + DateTimeOffset.UtcNow); + } + + public ProfilingSessionValidationResult ValidateSessionDefinition( + ProfilingSessionDefinition definition, + ProfilingPlatformCapabilities capabilities) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(definition.Name)) + errors.Add("A profiling session name is required."); + + if (string.IsNullOrWhiteSpace(definition.Target.Identifier)) + errors.Add("A profiling target identifier is required."); + + if (definition.Target.Platform != capabilities.Platform) + errors.Add($"Target platform {definition.Target.Platform} does not match {capabilities.DisplayName} capabilities."); + + if (!capabilities.SupportedTargetKinds.Contains(definition.Target.Kind)) + errors.Add($"{definition.Target.Kind} targets are not supported on {capabilities.DisplayName}."); + + if (!capabilities.SupportedScenarios.Contains(definition.Scenario)) + errors.Add($"{definition.Scenario} is not supported on {capabilities.DisplayName}."); + + if (definition.CaptureKinds.Count == 0) + errors.Add("At least one capture kind is required."); + + if (definition.Duration is { } duration && duration <= TimeSpan.Zero) + errors.Add("Duration must be greater than zero."); + + var unsupportedCaptureKinds = definition.CaptureKinds + .Where(kind => !capabilities.SupportedCaptureKinds.Contains(kind)) + .Distinct() + .ToArray(); + + if (unsupportedCaptureKinds.Length > 0) + errors.Add($"Unsupported capture kinds for {capabilities.DisplayName}: {string.Join(", ", unsupportedCaptureKinds)}."); + + return new ProfilingSessionValidationResult( + errors.Count == 0, + errors, + unsupportedCaptureKinds); + } +} diff --git a/src/MauiSherpa.Core/Services/ProfilingContextService.cs b/src/MauiSherpa.Core/Services/ProfilingContextService.cs new file mode 100644 index 00000000..db59b4a5 --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingContextService.cs @@ -0,0 +1,322 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.DevFlow; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +/// +/// Builds lightweight profiling summaries from live MauiDevFlow data. +/// +public class ProfilingContextService : IProfilingContextService +{ + private const string DefaultHost = "localhost"; + private const int DefaultAgentPort = 9223; + private const int DefaultBrokerPort = 19223; + + private readonly ILoggingService _logger; + private readonly Func _clientFactory; + + public ProfilingContextService(ILoggingService logger) + : this(logger, (host, port) => new DevFlowAgentClient(host, port)) + { + } + + internal ProfilingContextService( + ILoggingService logger, + Func clientFactory) + { + _logger = logger; + _clientFactory = clientFactory; + } + + public async Task> GetAvailableTargetsAsync(CancellationToken ct = default) + { + try + { + var agents = await DevFlowAgentClient.GetBrokerAgentsAsync(DefaultHost, DefaultBrokerPort, ct); + if (agents.Count > 0) + { + return agents + .OrderByDescending(a => a.ConnectedAt ?? DateTimeOffset.MinValue) + .ThenBy(a => a.AppName ?? a.Project ?? a.Id, StringComparer.OrdinalIgnoreCase) + .Select(a => new ProfilingTargetInfo( + a.Id, + DefaultHost, + a.Port, + a.AppName ?? a.Project ?? a.Id, + a.AppName, + a.Platform, + a.Project, + a.Tfm, + a.ConnectedAt, + true, + "broker")) + .ToList(); + } + } + catch (Exception ex) + { + _logger.LogDebug($"Profiling target discovery via broker failed: {ex.Message}"); + } + + var directTarget = await TryGetDirectTargetAsync(ct); + return directTarget is null ? Array.Empty() : new[] { directTarget }; + } + + public async Task GetSnapshotAsync(ProfilingSnapshotOptions options, CancellationToken ct = default) + { + var targets = await GetAvailableTargetsAsync(ct); + if (targets.Count == 0) + { + return new ProfilingSnapshotResult( + null, + "No local profiling targets are available. Start a MAUI app with MauiDevFlow enabled, then try again."); + } + + var resolvedTarget = ResolveTarget(targets, options.TargetId); + if (resolvedTarget.Target is null) + { + return new ProfilingSnapshotResult( + null, + options.TargetId is null + ? "No profiling target could be resolved." + : $"Profiling target '{options.TargetId}' was not found. Use list_profiling_targets to see available targets."); + } + + using var client = _clientFactory(resolvedTarget.Target.Host, resolvedTarget.Target.Port); + var status = await client.GetStatusAsync(ct); + if (status == null) + { + return new ProfilingSnapshotResult( + null, + $"Could not connect to profiling target '{resolvedTarget.Target.DisplayName}'."); + } + + var notes = new List(); + if (!string.IsNullOrWhiteSpace(resolvedTarget.Note)) + { + notes.Add(resolvedTarget.Note); + } + + var networkSampleSize = Math.Clamp(options.NetworkSampleSize, 5, 200); + ProfilingNetworkSummary? networkSummary = null; + if (options.IncludeNetworkSummary) + { + var requests = await client.GetNetworkRequestsAsync(ct); + networkSummary = BuildNetworkSummary(requests, networkSampleSize); + if (networkSummary.SampleSize == 0) + { + notes.Add("No network requests have been captured yet for this target."); + } + } + + ProfilingVisualTreeSummary? visualTreeSummary = null; + if (options.IncludeVisualTreeSummary) + { + var roots = await client.GetTreeAsync(ct: ct); + visualTreeSummary = BuildVisualTreeSummary(roots); + if (visualTreeSummary.TotalElementCount == 0) + { + notes.Add("The visual tree is currently empty or could not be enumerated."); + } + } + + var runtime = new ProfilingRuntimeInfo( + status.Agent, + status.Version, + status.Platform, + status.DeviceType, + status.Idiom, + status.AppName, + status.Running, + status.CdpReady, + status.CdpWebViewCount); + + return new ProfilingSnapshotResult( + new ProfilingSnapshot( + resolvedTarget.Target, + runtime, + networkSummary, + visualTreeSummary, + notes)); + } + + internal static ProfilingNetworkSummary BuildNetworkSummary( + IEnumerable requests, + int sampleSize) + { + var sample = requests + .OrderByDescending(r => r.Timestamp) + .Take(Math.Max(sampleSize, 0)) + .ToList(); + + if (sample.Count == 0) + { + return new ProfilingNetworkSummary(0, 0, 0, 0, 0, 0, 0, 0, Array.Empty()); + } + + var durations = sample + .Select(r => r.DurationMs) + .OrderBy(v => v) + .ToArray(); + + var successCount = sample.Count(r => string.IsNullOrWhiteSpace(r.Error) && (!r.StatusCode.HasValue || r.StatusCode.Value < 400)); + var failureCount = sample.Count - successCount; + + var slowestRequests = sample + .OrderByDescending(r => r.DurationMs) + .ThenBy(r => r.Url, StringComparer.OrdinalIgnoreCase) + .Take(5) + .Select(r => new ProfilingRequestSummary( + r.Method, + r.Url, + r.StatusCode, + r.DurationMs, + r.Error)) + .ToList(); + + return new ProfilingNetworkSummary( + sample.Count, + successCount, + failureCount, + sample.Average(r => r.DurationMs), + CalculatePercentile(durations, 0.95), + durations[^1], + sample.Sum(r => r.RequestSize ?? 0), + sample.Sum(r => r.ResponseSize ?? 0), + slowestRequests); + } + + internal static ProfilingVisualTreeSummary BuildVisualTreeSummary(IEnumerable roots) + { + var rootList = roots.ToList(); + var flattened = FlattenTree(rootList).ToList(); + + if (flattened.Count == 0) + { + return new ProfilingVisualTreeSummary(0, 0, 0, 0, 0, 0, Array.Empty()); + } + + var topElementTypes = flattened + .GroupBy(item => string.IsNullOrWhiteSpace(item.Element.Type) ? "Unknown" : item.Element.Type) + .Select(group => new ProfilingElementTypeCount(group.Key, group.Count())) + .OrderByDescending(group => group.Count) + .ThenBy(group => group.Type, StringComparer.OrdinalIgnoreCase) + .Take(10) + .ToList(); + + return new ProfilingVisualTreeSummary( + rootList.Count, + flattened.Count, + flattened.Count(item => item.Element.IsVisible), + flattened.Count(item => item.Element.IsFocused), + flattened.Count(item => item.Element.Gestures?.Count > 0), + flattened.Max(item => item.Depth), + topElementTypes); + } + + private async Task TryGetDirectTargetAsync(CancellationToken ct) + { + try + { + using var client = _clientFactory(DefaultHost, DefaultAgentPort); + var status = await client.GetStatusAsync(ct); + if (status == null) + { + return null; + } + + return new ProfilingTargetInfo( + "localhost", + DefaultHost, + DefaultAgentPort, + status.AppName ?? "Local MauiDevFlow agent", + status.AppName, + status.Platform, + null, + null, + null, + status.Running, + "direct"); + } + catch (Exception ex) + { + _logger.LogDebug($"Profiling direct target discovery failed: {ex.Message}"); + return null; + } + } + + private static (ProfilingTargetInfo? Target, string? Note) ResolveTarget( + IReadOnlyList targets, + string? targetId) + { + if (!string.IsNullOrWhiteSpace(targetId)) + { + var match = targets.FirstOrDefault(t => + t.Id.Equals(targetId, StringComparison.OrdinalIgnoreCase) || + t.DisplayName.Equals(targetId, StringComparison.OrdinalIgnoreCase) || + (t.AppName?.Equals(targetId, StringComparison.OrdinalIgnoreCase) ?? false)); + + return (match, null); + } + + if (targets.Count == 1) + { + return (targets[0], null); + } + + var selected = targets + .OrderByDescending(t => t.ConnectedAt ?? DateTimeOffset.MinValue) + .ThenBy(t => t.DisplayName, StringComparer.OrdinalIgnoreCase) + .First(); + + return (selected, $"Multiple profiling targets were available. Auto-selected '{selected.DisplayName}'."); + } + + private static IEnumerable<(DevFlowElementInfo Element, int Depth)> FlattenTree(IEnumerable roots) + { + var stack = new Stack<(DevFlowElementInfo Element, int Depth)>( + roots.Reverse().Select(root => (root, 1))); + + while (stack.Count > 0) + { + var current = stack.Pop(); + yield return current; + + if (current.Element.Children == null) + { + continue; + } + + for (var i = current.Element.Children.Count - 1; i >= 0; i--) + { + stack.Push((current.Element.Children[i], current.Depth + 1)); + } + } + } + + private static double CalculatePercentile(IReadOnlyList orderedValues, double percentile) + { + if (orderedValues.Count == 0) + { + return 0; + } + + if (orderedValues.Count == 1) + { + return orderedValues[0]; + } + + var position = (orderedValues.Count - 1) * percentile; + var lowerIndex = (int)Math.Floor(position); + var upperIndex = (int)Math.Ceiling(position); + + if (lowerIndex == upperIndex) + { + return orderedValues[lowerIndex]; + } + + var fraction = position - lowerIndex; + return orderedValues[lowerIndex] + ((orderedValues[upperIndex] - orderedValues[lowerIndex]) * fraction); + } +} diff --git a/src/MauiSherpa.Core/Services/ProfilingPrerequisitesService.cs b/src/MauiSherpa.Core/Services/ProfilingPrerequisitesService.cs new file mode 100644 index 00000000..6f604369 --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingPrerequisitesService.cs @@ -0,0 +1,492 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Workloads.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MauiSherpa.Core.Services; + +public class ProfilingPrerequisitesService : IProfilingPrerequisitesService +{ + private static readonly StringComparer ToolComparer = StringComparer.OrdinalIgnoreCase; + private static readonly Regex ToolListLineRegex = new( + @"^(?\S+)\s+(?\S+)\s+(?.+?)\s*$", + RegexOptions.Compiled); + private static readonly Regex VersionRegex = new(@"(?\d+(?:\.\d+)+(?:[-+][^\s]+)?)", RegexOptions.Compiled); + + private readonly IDoctorService _doctorService; + private readonly IPlatformService _platformService; + private readonly ILoggingService _loggingService; + private readonly ILogger _logger; + private readonly Func> _executeProcessAsync; + + public ProfilingPrerequisitesService( + IDoctorService doctorService, + IPlatformService platformService, + ILoggingService loggingService, + ILoggerFactory? loggerFactory = null) + : this(doctorService, platformService, loggingService, ExecuteProcessAsync, loggerFactory) + { + } + + internal ProfilingPrerequisitesService( + IDoctorService doctorService, + IPlatformService platformService, + ILoggingService loggingService, + Func> executeProcessAsync, + ILoggerFactory? loggerFactory = null) + { + _doctorService = doctorService; + _platformService = platformService; + _loggingService = loggingService; + _executeProcessAsync = executeProcessAsync; + _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + public async Task GetPrerequisitesAsync( + ProfilingTargetPlatform platform, + IReadOnlyList? captureKinds = null, + string? workingDirectory = null, + CancellationToken ct = default) + { + var normalizedCaptureKinds = captureKinds? + .Distinct() + .OrderBy(kind => kind) + .ToArray() ?? Array.Empty(); + + var doctorContext = await _doctorService.GetContextAsync(workingDirectory); + var doctorReport = await _doctorService.RunDoctorAsync(doctorContext); + var dotNetExecutablePath = _doctorService.GetDotNetExecutablePath(); + var effectiveWorkingDirectory = workingDirectory ?? doctorContext.WorkingDirectory; + + var statuses = new List(); + AddHostPlatformStatus(platform, statuses); + AddDoctorDependencyStatus( + doctorReport, + statuses, + ".NET SDK", + ProfilingPrerequisiteKind.DotNetSdk, + isRequired: true, + requiredVersion: doctorContext.ResolvedSdkVersion ?? doctorContext.PinnedSdkVersion, + recommendedVersion: doctorContext.ActiveSdkVersion); + AddPlatformDependencyStatuses(platform, doctorReport, statuses); + + var discoveredTools = await DiscoverDotNetToolsAsync(dotNetExecutablePath, effectiveWorkingDirectory, ct); + AddDotNetToolStatus( + statuses, + discoveredTools, + "dotnet-trace", + "dotnet-trace", + isRequired: RequiresTraceTool(normalizedCaptureKinds), + missingMessage: "Install dotnet-trace to capture EventPipe traces for profiling workflows."); + + AddDotNetToolStatus( + statuses, + discoveredTools, + "dotnet-gcdump", + "dotnet-gcdump", + isRequired: RequiresGcDumpTool(normalizedCaptureKinds), + missingMessage: "Install dotnet-gcdump to collect memory dumps for profiling sessions."); + + AddDotNetToolStatus( + statuses, + discoveredTools, + "dotnet-dsrouter", + "dotnet-dsrouter", + isRequired: RequiresDiagnosticsRouter(platform, normalizedCaptureKinds), + missingMessage: "Install dotnet-dsrouter to bridge diagnostics traffic for mobile profiling."); + + return new ProfilingPrerequisiteReport( + new ProfilingPrerequisiteContext( + platform, + normalizedCaptureKinds, + effectiveWorkingDirectory, + dotNetExecutablePath, + doctorContext), + statuses, + DateTimeOffset.UtcNow); + } + + private void AddHostPlatformStatus( + ProfilingTargetPlatform platform, + List statuses) + { + var hostPlatform = _platformService.PlatformName; + var (supported, message) = platform switch + { + ProfilingTargetPlatform.iOS or ProfilingTargetPlatform.MacCatalyst or ProfilingTargetPlatform.MacOS + when !_platformService.IsMacOS && !_platformService.IsMacCatalyst + => (false, $"{platform} profiling requires a macOS host."), + ProfilingTargetPlatform.Windows when !_platformService.IsWindows + => (false, "Windows profiling requires a Windows host."), + _ => (true, $"Host platform '{hostPlatform}' supports {platform} profiling prerequisites.") + }; + + statuses.Add(new ProfilingPrerequisiteStatus( + "Host Platform", + ProfilingPrerequisiteKind.HostPlatform, + supported ? DependencyStatusType.Ok : DependencyStatusType.Error, + IsRequired: true, + RequiredVersion: null, + RecommendedVersion: null, + InstalledVersion: hostPlatform, + Message: message)); + } + + private void AddPlatformDependencyStatuses( + ProfilingTargetPlatform platform, + DoctorReport doctorReport, + List statuses) + { + switch (platform) + { + case ProfilingTargetPlatform.Android: + AddDoctorDependencyStatus( + doctorReport, + statuses, + "Android SDK", + ProfilingPrerequisiteKind.AndroidToolchain, + isRequired: true); + AddDoctorDependencyStatus( + doctorReport, + statuses, + "Platform Tools", + ProfilingPrerequisiteKind.AndroidToolchain, + isRequired: true, + upgradeWarningToError: true, + missingMessage: "Android platform-tools (adb) are required to profile Android targets."); + break; + + case ProfilingTargetPlatform.iOS: + AddDoctorDependencyStatus( + doctorReport, + statuses, + "Xcode", + ProfilingPrerequisiteKind.AppleToolchain, + isRequired: true); + AddDoctorDependencyStatus( + doctorReport, + statuses, + "iOS Simulators", + ProfilingPrerequisiteKind.AppleToolchain, + isRequired: false); + break; + + case ProfilingTargetPlatform.MacCatalyst: + AddDoctorDependencyStatus( + doctorReport, + statuses, + "Xcode", + ProfilingPrerequisiteKind.AppleToolchain, + isRequired: true); + break; + + case ProfilingTargetPlatform.Windows: + statuses.Add(new ProfilingPrerequisiteStatus( + "Windows Toolchain", + ProfilingPrerequisiteKind.WindowsToolchain, + DependencyStatusType.Info, + IsRequired: false, + RequiredVersion: null, + RecommendedVersion: null, + InstalledVersion: _platformService.IsWindows ? _platformService.PlatformName : null, + Message: "Windows-specific toolchain validation is not implemented yet for profiling prerequisites.")); + break; + } + } + + private static bool RequiresTraceTool(IReadOnlyList captureKinds) => + captureKinds.Count == 0 || + captureKinds.Any(kind => kind is ProfilingCaptureKind.Startup + or ProfilingCaptureKind.Cpu + or ProfilingCaptureKind.Memory + or ProfilingCaptureKind.Rendering + or ProfilingCaptureKind.Energy + or ProfilingCaptureKind.SystemTrace); + + private static bool RequiresGcDumpTool(IReadOnlyList captureKinds) => + captureKinds.Contains(ProfilingCaptureKind.Memory); + + private static bool RequiresDiagnosticsRouter( + ProfilingTargetPlatform platform, + IReadOnlyList captureKinds) => + platform is ProfilingTargetPlatform.Android or ProfilingTargetPlatform.iOS && + RequiresTraceTool(captureKinds); + + private void AddDoctorDependencyStatus( + DoctorReport doctorReport, + List statuses, + string dependencyName, + ProfilingPrerequisiteKind kind, + bool isRequired, + string? requiredVersion = null, + string? recommendedVersion = null, + bool upgradeWarningToError = false, + string? missingMessage = null) + { + var dependency = doctorReport.Dependencies + .FirstOrDefault(item => item.Name.Equals(dependencyName, StringComparison.OrdinalIgnoreCase)); + + if (dependency is null) + { + statuses.Add(new ProfilingPrerequisiteStatus( + dependencyName, + kind, + isRequired ? DependencyStatusType.Error : DependencyStatusType.Warning, + isRequired, + requiredVersion, + recommendedVersion, + InstalledVersion: null, + Message: missingMessage ?? $"{dependencyName} could not be validated from the doctor report.")); + return; + } + + var status = dependency.Status; + if (upgradeWarningToError && status == DependencyStatusType.Warning) + status = DependencyStatusType.Error; + + var message = dependency.Message; + if (upgradeWarningToError && dependency.Status == DependencyStatusType.Warning && !string.IsNullOrWhiteSpace(message)) + message = $"{message} This is required for profiling readiness."; + + statuses.Add(new ProfilingPrerequisiteStatus( + dependency.Name, + kind, + status, + isRequired, + requiredVersion ?? dependency.RequiredVersion, + recommendedVersion ?? dependency.RecommendedVersion, + dependency.InstalledVersion, + message, + IsFixable: dependency.IsFixable, + FixAction: dependency.FixAction)); + } + + private void AddDotNetToolStatus( + List statuses, + IReadOnlyDictionary discoveredTools, + string commandName, + string packageId, + bool isRequired, + string missingMessage) + { + if (!isRequired && !discoveredTools.ContainsKey(commandName)) + return; + + if (!discoveredTools.TryGetValue(commandName, out var tool)) + { + statuses.Add(new ProfilingPrerequisiteStatus( + commandName, + ProfilingPrerequisiteKind.DotNetTool, + isRequired ? DependencyStatusType.Error : DependencyStatusType.Warning, + isRequired, + RequiredVersion: null, + RecommendedVersion: null, + InstalledVersion: null, + Message: missingMessage, + DiscoveredBy: null, + ExecutablePath: null, + IsFixable: true, + FixAction: $"install-dotnet-tool:{packageId}", + SuggestedCommand: $"dotnet tool install --global {packageId}")); + return; + } + + var message = $"{commandName} {tool.Version} discovered via {tool.Source}."; + + statuses.Add(new ProfilingPrerequisiteStatus( + commandName, + ProfilingPrerequisiteKind.DotNetTool, + DependencyStatusType.Ok, + isRequired, + RequiredVersion: null, + RecommendedVersion: null, + InstalledVersion: tool.Version, + Message: message, + DiscoveredBy: tool.Source, + ExecutablePath: tool.ExecutablePath, + IsFixable: false, + FixAction: null, + SuggestedCommand: null)); + } + + private async Task> DiscoverDotNetToolsAsync( + string dotNetExecutablePath, + string? workingDirectory, + CancellationToken ct) + { + var discoveredTools = new Dictionary(ToolComparer); + + if (!string.IsNullOrWhiteSpace(workingDirectory)) + { + foreach (var tool in await TryListDotNetToolsAsync(dotNetExecutablePath, ["tool", "list", "--local"], "local-manifest", workingDirectory, ct)) + discoveredTools[tool.Command] = tool; + } + + foreach (var tool in await TryListDotNetToolsAsync(dotNetExecutablePath, ["tool", "list", "--global"], "global-tool", null, ct)) + discoveredTools[tool.Command] = tool; + + foreach (var command in new[] { "dotnet-trace", "dotnet-gcdump", "dotnet-dsrouter" }) + { + if (discoveredTools.ContainsKey(command)) + continue; + + var shimPath = ResolveGlobalToolShim(command); + if (shimPath is null) + continue; + + var result = await _executeProcessAsync( + new ProcessRequest(shimPath, ["--version"], workingDirectory), + ct); + + if (!result.Success) + continue; + + var version = TryExtractVersion(result.Output) ?? TryExtractVersion(result.Error); + if (version is null) + continue; + + discoveredTools[command] = new DiscoveredDotNetTool(command, version, "global-shim", shimPath); + } + + return discoveredTools; + } + + private async Task> TryListDotNetToolsAsync( + string dotNetExecutablePath, + string[] arguments, + string source, + string? workingDirectory, + CancellationToken ct) + { + try + { + var result = await _executeProcessAsync( + new ProcessRequest(dotNetExecutablePath, arguments, workingDirectory), + ct); + + if (!result.Success) + { + _logger.LogDebug("Failed to list {Source} dotnet tools: {Error}", source, result.Error); + return Array.Empty(); + } + + return ParseDotNetToolList(result.Output, source); + } + catch (Exception ex) + { + _loggingService.LogDebug($"Failed to discover {source} dotnet tools: {ex.Message}"); + return Array.Empty(); + } + } + + internal static IReadOnlyList ParseDotNetToolList(string output, string source) + { + var results = new List(); + var lines = output + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .ToArray(); + + var separatorIndex = Array.FindIndex(lines, line => line.StartsWith("---", StringComparison.Ordinal)); + if (separatorIndex < 0) + return results; + + foreach (var line in lines[(separatorIndex + 1)..]) + { + var match = ToolListLineRegex.Match(line); + if (!match.Success) + continue; + + var version = match.Groups["version"].Value; + var commands = match.Groups["commands"].Value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var command in commands) + results.Add(new DiscoveredDotNetTool(command, version, source, ResolveGlobalToolShim(command))); + } + + return results; + } + + private static int? GetSdkMajorVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + + if (SdkVersion.TryParse(version, out var sdkVersion) && sdkVersion is not null) + return sdkVersion.Major; + + var match = Regex.Match(version, @"^(?\d+)"); + return match.Success && int.TryParse(match.Groups["major"].Value, out var major) ? major : null; + } + + private static string? TryExtractVersion(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + return null; + + var match = VersionRegex.Match(output); + return match.Success ? match.Groups["version"].Value : null; + } + + private static string? ResolveGlobalToolShim(string command) + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile)) + return null; + + var executableName = OperatingSystem.IsWindows() ? $"{command}.exe" : command; + var candidate = Path.Combine(userProfile, ".dotnet", "tools", executableName); + return File.Exists(candidate) ? candidate : null; + } + + private static async Task ExecuteProcessAsync(ProcessRequest request, CancellationToken ct) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = request.Command, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = request.WorkingDirectory ?? Environment.CurrentDirectory + }; + + foreach (var arg in request.Arguments) + startInfo.ArgumentList.Add(arg); + + if (request.Environment is not null) + { + foreach (var (key, value) in request.Environment) + startInfo.Environment[key] = value; + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + var outputTask = process.StandardOutput.ReadToEndAsync(ct); + var errorTask = process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct); + + var output = await outputTask; + var error = await errorTask; + var finalState = process.ExitCode == 0 ? ProcessState.Completed : ProcessState.Failed; + + return new ProcessResult(process.ExitCode, output, error, TimeSpan.Zero, finalState); + } + catch (Exception ex) + { + return new ProcessResult(-1, string.Empty, ex.Message, TimeSpan.Zero, ProcessState.Failed); + } + } + + internal sealed record DiscoveredDotNetTool( + string Command, + string Version, + string Source, + string? ExecutablePath); +} diff --git a/src/MauiSherpa.Core/Skills/maui-profiling/SKILL.md b/src/MauiSherpa.Core/Skills/maui-profiling/SKILL.md new file mode 100644 index 00000000..778be7bf --- /dev/null +++ b/src/MauiSherpa.Core/Skills/maui-profiling/SKILL.md @@ -0,0 +1,26 @@ +--- +name: maui-profiling +description: Build lightweight profiling context for a running .NET MAUI app using local MauiDevFlow status, network, and visual-tree summaries instead of uploading raw trace artifacts. Use when investigating slow screens, performance regressions, excessive network chatter, or deciding whether deeper trace capture is needed. +--- + +# MAUI Profiling Context + +Use structured, local summaries first. Avoid asking for raw `.nettrace`, `gcdump`, or other large artifacts unless the lightweight snapshot is insufficient. + +## Workflow + +1. Run `get_profiling_catalog` to understand supported scenarios and platform capabilities. +2. Run `list_profiling_targets` to discover locally running MauiDevFlow-enabled apps. +3. Run `get_profiling_snapshot` for the relevant target. +4. Review: + - runtime status (platform, device type, WebView readiness) + - recent network behavior (sample size, failures, latency, slowest requests) + - visual tree complexity (element counts, depth, top element types) +5. Use the summary to narrow the investigation before requesting any deeper traces. + +## Guidance + +- Prefer the default lightweight snapshot first. +- Increase `networkSampleSize` only when recent request volume is too low to explain the issue. +- If multiple targets are connected, specify `targetId` from `list_profiling_targets`. +- Treat the snapshot as context for follow-up recommendations, not as a full profiler replacement. diff --git a/src/MauiSherpa.MacOS/MacOSApp.cs b/src/MauiSherpa.MacOS/MacOSApp.cs index c8a21608..fb1b38b3 100644 --- a/src/MauiSherpa.MacOS/MacOSApp.cs +++ b/src/MauiSherpa.MacOS/MacOSApp.cs @@ -146,6 +146,12 @@ void AddAppMenuItems(BlazorContentPage blazorPage) doctorItem.Target = doctorHandler; appMenu.InsertItem(doctorItem, insertIndex++); + var profilingHandler = new MenuActionHandler(() => blazorPage.NavigateToRoute("/profiling")); + _menuHandlers.Add(profilingHandler); + var profilingItem = new NSMenuItem("Profiling", new ObjCRuntime.Selector("menuAction:"), ""); + profilingItem.Target = profilingHandler; + appMenu.InsertItem(profilingItem, insertIndex++); + var sep2 = NSMenuItem.SeparatorItem; appMenu.InsertItem(sep2, insertIndex); } @@ -202,25 +208,18 @@ FlyoutPage CreateFlyoutPage(BlazorContentPage blazorPage) }, new MacOSSidebarItem { - Title = "DevFlow", + Title = "Tools", Children = new List { + new() { Title = "Profiling", SystemImage = "chart.bar.xaxis", Tag = "/profiling" }, new() { Title = "App Inspector", SystemImage = "wand.and.stars", Tag = "/devflow" }, +#if DEBUG + new() { Title = "Debug UI", SystemImage = "ant", Tag = "/debug" }, +#endif } }, }; -#if DEBUG - sidebarItems.Add(new MacOSSidebarItem - { - Title = "Development", - Children = new List - { - new() { Title = "Debug UI", SystemImage = "ant", Tag = "/debug" }, - } - }); -#endif - MacOSFlyoutPage.SetSidebarItems(flyoutPage, sidebarItems); _sidebarItems = sidebarItems; MacOSFlyoutPage.SetSidebarSelectionChanged(flyoutPage, item => diff --git a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs index 4a63db4c..8ac0b8bb 100644 --- a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs +++ b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs @@ -94,12 +94,18 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -199,6 +205,11 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/src/MauiSherpa.ProfilingSample/App.cs b/src/MauiSherpa.ProfilingSample/App.cs new file mode 100644 index 00000000..9ff73f0f --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/App.cs @@ -0,0 +1,17 @@ +namespace MauiSherpa.ProfilingSample; + +public sealed class App : Application +{ + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window + { + Title = "Sherpa Profiling Sample", + Width = 1440, + Height = 960, + MinimumWidth = 960, + MinimumHeight = 720, + Page = new MainPage() + }; + } +} diff --git a/src/MauiSherpa.ProfilingSample/Components/App.razor b/src/MauiSherpa.ProfilingSample/Components/App.razor new file mode 100644 index 00000000..c52170b7 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Components/App.razor @@ -0,0 +1,14 @@ + + + + + + + +
+

Route not found

+

The profiling sample only exposes the main dashboard.

+
+
+
+
diff --git a/src/MauiSherpa.ProfilingSample/Components/MainLayout.razor b/src/MauiSherpa.ProfilingSample/Components/MainLayout.razor new file mode 100644 index 00000000..9646dd6e --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Components/MainLayout.razor @@ -0,0 +1,36 @@ +@inherits LayoutComponentBase + +
+
+
+
Sherpa diagnostics playground
+
+

Sherpa Profiling Sample

+ Multiple manual workloads +
+

A standalone MAUI Blazor sample with CPU, memory, rendering, scrolling, and network-heavy paths designed to make performance investigations obvious.

+
+ +
+
Debug builds expose MauiDevFlow for local profiling snapshots.
+ +
+
+ +
+ @Body +
+
+ +@code { + private bool IsDarkMode { get; set; } = true; + + private string ThemeClass => IsDarkMode ? "theme-dark" : "theme-light"; + + private void ToggleTheme() + { + IsDarkMode = !IsDarkMode; + } +} diff --git a/src/MauiSherpa.ProfilingSample/MainPage.cs b/src/MauiSherpa.ProfilingSample/MainPage.cs new file mode 100644 index 00000000..1f591b65 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/MainPage.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Components.WebView.Maui; + +namespace MauiSherpa.ProfilingSample; + +public sealed class MainPage : ContentPage +{ + public MainPage() + { + var blazorWebView = new BlazorWebView + { + HostPage = "wwwroot/index.html" + }; + + blazorWebView.RootComponents.Add(new RootComponent + { + Selector = "#app", + ComponentType = typeof(Components.App) + }); + + Content = blazorWebView; + } +} diff --git a/src/MauiSherpa.ProfilingSample/MauiProgram.cs b/src/MauiSherpa.ProfilingSample/MauiProgram.cs new file mode 100644 index 00000000..f8368923 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/MauiProgram.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; +using MauiSherpa.ProfilingSample.Services; +#if DEBUG +using MauiDevFlow.Agent; +using MauiDevFlow.Blazor; +#endif + +namespace MauiSherpa.ProfilingSample; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }); + + builder.Services.AddMauiBlazorWebView(); + builder.Services.AddSingleton(); + +#if DEBUG + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); + builder.AddMauiDevFlowAgent(); + builder.AddMauiBlazorDevFlowTools(); +#endif + + return builder.Build(); + } +} diff --git a/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj new file mode 100644 index 00000000..c54ada97 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj @@ -0,0 +1,39 @@ + + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + + Exe + MauiSherpa.ProfilingSample + true + true + enable + enable + false + 9241 + + Sherpa Profiling Sample + codes.redth.mauisherpa.profilingsample + 1.0.0 + 1 + + 24.0 + 15.0 + 15.0 + + + + + + + + + + + + + + + + + diff --git a/src/MauiSherpa.ProfilingSample/Models/ProfilingScenarioModels.cs b/src/MauiSherpa.ProfilingSample/Models/ProfilingScenarioModels.cs new file mode 100644 index 00000000..7921fe96 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Models/ProfilingScenarioModels.cs @@ -0,0 +1,93 @@ +namespace MauiSherpa.ProfilingSample.Models; + +public sealed record RenderTile(int Id, string Label, int Hue, double Scale, int LoadValue, bool IsHot); + +public sealed record FeedItem(int Id, string Title, string Category, int ActiveUsers, int DurationMs, int Score, string Summary); + +public sealed record MemorySnapshot(string Id, DateTimeOffset Timestamp, long RetainedBytes, long ManagedHeapBytes, int ChunkCount); + +public sealed class NetworkRunResult +{ + public string Url { get; set; } = string.Empty; + + public int? StatusCode { get; set; } + + public bool Success { get; set; } + + public long DurationMs { get; set; } + + public string? Error { get; set; } + + public long Bytes { get; set; } +} + +public sealed class NetworkBurstOptions +{ + public string Mode { get; set; } = "local"; + + public int RequestCount { get; set; } = 12; +} + +public sealed class CpuScenarioState +{ + public bool IsRunning { get; set; } + + public int WorkerCount { get; set; } + + public int DurationSeconds { get; set; } + + public long IterationsCompleted { get; set; } + + public double LastBatchMilliseconds { get; set; } + + public string Status { get; set; } = "Idle"; + + public DateTimeOffset? StartedAt { get; set; } +} + +public sealed class MemoryScenarioState +{ + public bool IsChurning { get; set; } + + public int ChunkCount { get; set; } + + public long RetainedBytes { get; set; } + + public long ManagedHeapBytes { get; set; } + + public string Status { get; set; } = "Idle"; + + public IReadOnlyList Snapshots { get; set; } = Array.Empty(); +} + +public sealed class RenderingScenarioState +{ + public bool IsAnimating { get; set; } + + public int Frame { get; set; } + + public IReadOnlyList Tiles { get; set; } = Array.Empty(); + + public IReadOnlyList FeedItems { get; set; } = Array.Empty(); + + public string Status { get; set; } = "Ready"; +} + +public sealed class NetworkScenarioState +{ + public bool IsRunning { get; set; } + + public string LastMode { get; set; } = "Idle"; + + public int SuccessCount { get; set; } + + public int FailureCount { get; set; } + + public double AverageDurationMs { get; set; } + + public long TotalBytes { get; set; } + + public string Status { get; set; } = "Idle"; + + public IReadOnlyList RecentRequests { get; set; } = Array.Empty(); +} diff --git a/src/MauiSherpa.ProfilingSample/Pages/Home.razor b/src/MauiSherpa.ProfilingSample/Pages/Home.razor new file mode 100644 index 00000000..f72c626f --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Pages/Home.razor @@ -0,0 +1,415 @@ +@page "/" +@implements IDisposable +@inject ProfilingScenarioService ScenarioService +@inject IJSRuntime JS + +Sherpa Profiling Sample + +
+
+
+
Profiling starter kit
+

Trigger realistic hotspots in a few clicks

+

Use the controls below to spin up CPU pressure, retain large heaps, churn the visual tree, and generate browser-visible fetch traffic from the WebView.

+
+ +
+ + + + +
+
+ +
+
+ Working set + @FormatBytes(_process.WorkingSetBytes) + Private: @FormatBytes(_process.PrivateBytes) +
+
+ Managed heap + @FormatBytes(_process.ManagedHeapBytes) + GC counts: @_process.Gen0Collections / @_process.Gen1Collections / @_process.Gen2Collections +
+
+ CPU loops + @ScenarioService.Cpu.IterationsCompleted.ToString("N0") + Last batch: @FormatDuration(ScenarioService.Cpu.LastBatchMilliseconds) +
+
+ Retained memory + @FormatBytes(ScenarioService.Memory.RetainedBytes) + Chunks: @ScenarioService.Memory.ChunkCount +
+
+ Visual surface + @ScenarioService.Rendering.Tiles.Count tile(s) + Feed rows: @ScenarioService.Rendering.FeedItems.Count +
+
+ Network summary + @ScenarioService.Network.SuccessCount success / @ScenarioService.Network.FailureCount fail + Avg: @FormatDuration(ScenarioService.Network.AverageDurationMs) +
+
+ +
+
+
+
CPU
+

Prime + math worker loop

+
+ @ScenarioService.Cpu.Status +
+ +

Starts multiple background workers that repeatedly scan for primes and sort floating point batches. It creates an obvious CPU ramp without needing external dependencies.

+ +
+ + +
+ +
+ + +
+ +
+
+ Started + @(ScenarioService.Cpu.StartedAt?.ToLocalTime().ToString("T") ?? "Idle") +
+
+ Worker count + @ScenarioService.Cpu.WorkerCount +
+
+ Target duration + @ScenarioService.Cpu.DurationSeconds s +
+
+ Iterations + @ScenarioService.Cpu.IterationsCompleted.ToString("N0") +
+
+
+ +
+
+
+
Memory
+

Retained allocations + background churn

+
+ @ScenarioService.Memory.Status +
+ +

Retain object graphs backed by 1 MB buffers, or enable churn to create repeated allocation and trim cycles that are easy to spot in heap or GC diagnostics.

+ +
+ +
+ Managed heap now + @FormatBytes(ScenarioService.Memory.ManagedHeapBytes) +
+
+ +
+ + + + + +
+ +
+ @if (ScenarioService.Memory.Snapshots.Count == 0) + { +
No memory markers yet.
+ } + else + { + @foreach (var snapshot in ScenarioService.Memory.Snapshots) + { +
+ @snapshot.Timestamp.ToString("T") + @FormatBytes(snapshot.RetainedBytes) retained + @FormatBytes(snapshot.ManagedHeapBytes) heap + @snapshot.ChunkCount chunk(s) +
+ } + } +
+
+ +
+
+
+
Rendering & scrolling
+

Dense tile wall and long feed

+
+ @ScenarioService.Rendering.Status +
+ +

The tile wall creates a broad visual tree that can animate, while the feed creates a tall scroll surface packed with text, metadata, and progress bars.

+ +
+ + + + +
+ +
+
+ @foreach (var tile in ScenarioService.Rendering.Tiles) + { +
+ @tile.Label + @tile.LoadValue% +
+ } +
+ +
+
Frame @ScenarioService.Rendering.Frame • @ScenarioService.Rendering.FeedItems.Count scroll row(s)
+
+ @foreach (var item in ScenarioService.Rendering.FeedItems) + { +
+
+
+ @item.Title + @item.Category +
+ @item.DurationMs ms +
+

@item.Summary

+
+ @item.ActiveUsers active users +
+ @item.Score score +
+
+ } +
+
+
+
+ +
+
+
+
Network-ish
+

WebView-visible fetch bursts

+
+ @ScenarioService.Network.Status +
+ +

These runs fire fetches from JavaScript inside the Blazor WebView so MauiDevFlow and Sherpa can observe them. Local mode always works; remote mixed mode intentionally includes slow and failing requests when available.

+ +
+ +
+ Last mode + @ScenarioService.Network.LastMode +
+
+ +
+ + +
+ +
+
+ Success + @ScenarioService.Network.SuccessCount +
+
+ Failure + @ScenarioService.Network.FailureCount +
+
+ Average + @FormatDuration(ScenarioService.Network.AverageDurationMs) +
+
+ Payload + @FormatBytes(ScenarioService.Network.TotalBytes) +
+
+ +
+ @if (ScenarioService.Network.RecentRequests.Count == 0) + { +
No request history yet.
+ } + else + { + @foreach (var request in ScenarioService.Network.RecentRequests) + { +
+
+ @request.StatusCode?.ToString() ?? "ERR" + @request.Url +
+
+ @FormatDuration(request.DurationMs) + @(request.Success ? "ok" : request.Error ?? "failed") +
+
+ } + } +
+
+
+ +@code { + private int _cpuWorkers = Math.Clamp(Environment.ProcessorCount / 2, 1, 4); + private int _cpuDurationSeconds = 20; + private int _memoryMegabytes = 64; + private int _networkRequestCount = 12; + private ProcessSnapshot _process = ProcessSnapshot.Capture(); + private CancellationTokenSource _metricsCts = new(); + + protected override void OnInitialized() + { + ScenarioService.Changed += OnScenarioChanged; + _ = RefreshProcessMetricsAsync(_metricsCts.Token); + } + + private Task StartCpuScenarioAsync() + { + return ScenarioService.StartCpuStressAsync(_cpuWorkers, TimeSpan.FromSeconds(_cpuDurationSeconds)); + } + + private Task RunLocalNetworkScenarioAsync() + { + return RunNetworkScenarioAsync("local"); + } + + private Task RunRemoteNetworkScenarioAsync() + { + return RunNetworkScenarioAsync("remote-mixed"); + } + + private async Task RunNetworkScenarioAsync(string mode) + { + ScenarioService.BeginNetworkRun(mode, _networkRequestCount); + + try + { + var results = await JS.InvokeAsync( + "profilingSample.runNetworkBurst", + new NetworkBurstOptions + { + Mode = mode, + RequestCount = _networkRequestCount + }); + + ScenarioService.CompleteNetworkRun(mode, results); + } + catch (Exception ex) + { + ScenarioService.FailNetworkRun(mode, ex.Message); + } + } + + private async Task RefreshProcessMetricsAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + while (!cancellationToken.IsCancellationRequested) + { + _process = ProcessSnapshot.Capture(); + await InvokeAsync(StateHasChanged); + + try + { + await timer.WaitForNextTickAsync(cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private void OnScenarioChanged() + { + _ = InvokeAsync(StateHasChanged); + } + + private static string FormatBytes(long bytes) + { + string[] units = ["B", "KB", "MB", "GB"]; + double value = bytes; + var unit = 0; + while (value >= 1024 && unit < units.Length - 1) + { + value /= 1024; + unit++; + } + + return $"{value:0.#} {units[unit]}"; + } + + private static string FormatDuration(double durationMs) + { + if (durationMs >= 1000) + { + return $"{durationMs / 1000:0.00}s"; + } + + return $"{durationMs:0.#} ms"; + } + + public void Dispose() + { + ScenarioService.Changed -= OnScenarioChanged; + _metricsCts.Cancel(); + _metricsCts.Dispose(); + } + + private sealed record ProcessSnapshot( + long WorkingSetBytes, + long PrivateBytes, + long ManagedHeapBytes, + int Gen0Collections, + int Gen1Collections, + int Gen2Collections) + { + public static ProcessSnapshot Capture() + { + try + { + var process = Process.GetCurrentProcess(); + process.Refresh(); + return new ProcessSnapshot( + process.WorkingSet64, + process.PrivateMemorySize64, + GC.GetTotalMemory(false), + GC.CollectionCount(0), + GC.CollectionCount(1), + GC.CollectionCount(2)); + } + catch + { + return new ProcessSnapshot(0, 0, GC.GetTotalMemory(false), GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2)); + } + } + } +} diff --git a/src/MauiSherpa.ProfilingSample/Platforms/Android/AndroidManifest.xml b/src/MauiSherpa.ProfilingSample/Platforms/Android/AndroidManifest.xml new file mode 100644 index 00000000..ecf6baa5 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/MauiSherpa.ProfilingSample/Platforms/Android/MainActivity.cs b/src/MauiSherpa.ProfilingSample/Platforms/Android/MainActivity.cs new file mode 100644 index 00000000..768c2fb9 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/Android/MainActivity.cs @@ -0,0 +1,18 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace MauiSherpa.ProfilingSample; + +[Activity( + Theme = "@style/Maui.SplashTheme", + MainLauncher = true, + ConfigurationChanges = ConfigChanges.ScreenSize + | ConfigChanges.Orientation + | ConfigChanges.UiMode + | ConfigChanges.ScreenLayout + | ConfigChanges.SmallestScreenSize + | ConfigChanges.Density)] +public sealed class MainActivity : MauiAppCompatActivity +{ +} diff --git a/src/MauiSherpa.ProfilingSample/Platforms/Android/MainApplication.cs b/src/MauiSherpa.ProfilingSample/Platforms/Android/MainApplication.cs new file mode 100644 index 00000000..63c63288 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/Android/MainApplication.cs @@ -0,0 +1,15 @@ +using Android.App; +using Android.Runtime; + +namespace MauiSherpa.ProfilingSample; + +[Application] +public sealed class MainApplication : MauiApplication +{ + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/MauiSherpa.ProfilingSample/Platforms/Android/Resources/values/colors.xml b/src/MauiSherpa.ProfilingSample/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 00000000..892ae109 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #4F46E5 + #3730A3 + #3730A3 + diff --git a/src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/AppDelegate.cs b/src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 00000000..3e66b3db --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace MauiSherpa.ProfilingSample; + +[Register("AppDelegate")] +public sealed class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/Program.cs b/src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/Program.cs new file mode 100644 index 00000000..19a401f6 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,11 @@ +using UIKit; + +namespace MauiSherpa.ProfilingSample; + +public static class Program +{ + private static void Main(string[] args) + { + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/src/MauiSherpa.ProfilingSample/Platforms/iOS/AppDelegate.cs b/src/MauiSherpa.ProfilingSample/Platforms/iOS/AppDelegate.cs new file mode 100644 index 00000000..3e66b3db --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace MauiSherpa.ProfilingSample; + +[Register("AppDelegate")] +public sealed class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/MauiSherpa.ProfilingSample/Platforms/iOS/Info.plist b/src/MauiSherpa.ProfilingSample/Platforms/iOS/Info.plist new file mode 100644 index 00000000..ecb7f719 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/iOS/Info.plist @@ -0,0 +1,32 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/src/MauiSherpa.ProfilingSample/Platforms/iOS/Program.cs b/src/MauiSherpa.ProfilingSample/Platforms/iOS/Program.cs new file mode 100644 index 00000000..19a401f6 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/iOS/Program.cs @@ -0,0 +1,11 @@ +using UIKit; + +namespace MauiSherpa.ProfilingSample; + +public static class Program +{ + private static void Main(string[] args) + { + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/src/MauiSherpa.ProfilingSample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/src/MauiSherpa.ProfilingSample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..9c604a59 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,41 @@ + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + diff --git a/src/MauiSherpa.ProfilingSample/Services/ProfilingScenarioService.cs b/src/MauiSherpa.ProfilingSample/Services/ProfilingScenarioService.cs new file mode 100644 index 00000000..3e30d48b --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/Services/ProfilingScenarioService.cs @@ -0,0 +1,525 @@ +using System.Diagnostics; +using MauiSherpa.ProfilingSample.Models; + +namespace MauiSherpa.ProfilingSample.Services; + +public sealed class ProfilingScenarioService : IDisposable +{ + private readonly object _memoryGate = new(); + private readonly object _renderGate = new(); + private readonly List _retainedMemory = []; + private readonly Random _random = new(4172); + + private CancellationTokenSource? _cpuCts; + private CancellationTokenSource? _memoryChurnCts; + private Timer? _animationTimer; + private long _cpuIterations; + private int _cpuRunId; + private int _nextFeedId = 1; + private int _nextChunkId = 1; + + public event Action? Changed; + + public CpuScenarioState Cpu { get; } = new(); + + public MemoryScenarioState Memory { get; } = new(); + + public RenderingScenarioState Rendering { get; } = new(); + + public NetworkScenarioState Network { get; } = new(); + + public ProfilingScenarioService() + { + ResetRenderingScene(); + RefreshMemoryState("Ready to retain or churn allocations."); + Network.Status = "Run a local or remote burst to generate visible request traffic."; + } + + public Task StartCpuStressAsync(int workerCount, TimeSpan duration) + { + StopCpuStress(); + + var runId = Interlocked.Increment(ref _cpuRunId); + var tokenSource = new CancellationTokenSource(); + _cpuCts = tokenSource; + Interlocked.Exchange(ref _cpuIterations, 0); + + Cpu.IsRunning = true; + Cpu.WorkerCount = Math.Clamp(workerCount, 1, Math.Max(1, Environment.ProcessorCount)); + Cpu.DurationSeconds = Math.Clamp((int)Math.Ceiling(duration.TotalSeconds), 5, 180); + Cpu.StartedAt = DateTimeOffset.UtcNow; + Cpu.Status = $"Crunching prime checks with {Cpu.WorkerCount} worker(s)."; + Cpu.LastBatchMilliseconds = 0; + Cpu.IterationsCompleted = 0; + NotifyChanged(); + + var tasks = Enumerable.Range(0, Cpu.WorkerCount) + .Select(worker => Task.Run(() => RunCpuWorker(worker, tokenSource.Token), tokenSource.Token)) + .ToArray(); + + _ = Task.Run(async () => + { + var completedNaturally = false; + + try + { + await Task.Delay(TimeSpan.FromSeconds(Cpu.DurationSeconds), tokenSource.Token); + completedNaturally = true; + } + catch (OperationCanceledException) + { + } + finally + { + tokenSource.Cancel(); + } + + try + { + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) + { + } + + if (runId != Volatile.Read(ref _cpuRunId)) + { + return; + } + + Cpu.IsRunning = false; + Cpu.IterationsCompleted = Interlocked.Read(ref _cpuIterations); + Cpu.Status = completedNaturally + ? "Scheduled CPU run completed." + : "CPU run stopped."; + NotifyChanged(); + tokenSource.Dispose(); + }); + + return Task.CompletedTask; + } + + public void StopCpuStress() + { + _cpuCts?.Cancel(); + _cpuCts = null; + + if (Cpu.IsRunning) + { + Cpu.IsRunning = false; + Cpu.Status = "CPU run stopped."; + NotifyChanged(); + } + } + + public void AllocateRetainedMemory(int megabytes) + { + var clampedMegabytes = Math.Clamp(megabytes, 8, 512); + + lock (_memoryGate) + { + _retainedMemory.Add(CreateChunk(clampedMegabytes)); + RefreshMemoryState($"Retained another {clampedMegabytes} MB across object graphs and byte buffers."); + } + + NotifyChanged(); + } + + public void ReleaseHalfMemory() + { + lock (_memoryGate) + { + var removeCount = _retainedMemory.Count / 2; + if (removeCount > 0) + { + _retainedMemory.RemoveRange(0, removeCount); + } + + RefreshMemoryState(removeCount == 0 + ? "Nothing to release yet." + : $"Released {removeCount} retained chunk(s)."); + } + + NotifyChanged(); + } + + public void ClearRetainedMemory() + { + lock (_memoryGate) + { + _retainedMemory.Clear(); + RefreshMemoryState("Cleared retained allocations."); + } + + NotifyChanged(); + } + + public void CaptureMemorySnapshot() + { + lock (_memoryGate) + { + var snapshot = new MemorySnapshot( + $"snap-{DateTimeOffset.UtcNow:HHmmssfff}", + DateTimeOffset.Now, + Memory.RetainedBytes, + GC.GetTotalMemory(false), + Memory.ChunkCount); + + Memory.Snapshots = Memory.Snapshots + .Prepend(snapshot) + .Take(6) + .ToArray(); + + Memory.Status = "Captured a point-in-time heap snapshot marker."; + } + + NotifyChanged(); + } + + public void ToggleMemoryChurn() + { + if (Memory.IsChurning) + { + StopMemoryChurn(); + return; + } + + _memoryChurnCts?.Cancel(); + var churnCts = new CancellationTokenSource(); + _memoryChurnCts = churnCts; + Memory.IsChurning = true; + Memory.Status = "Background churn is allocating and trimming every 350 ms."; + NotifyChanged(); + + _ = Task.Run(async () => + { + while (!churnCts.IsCancellationRequested) + { + lock (_memoryGate) + { + _retainedMemory.Add(CreateChunk(8)); + if (_retainedMemory.Count > 18) + { + _retainedMemory.RemoveAt(0); + } + + RefreshMemoryState("Background churn is allocating and trimming every 350 ms."); + } + + NotifyChanged(); + + try + { + await Task.Delay(350, churnCts.Token); + } + catch (OperationCanceledException) + { + break; + } + } + + churnCts.Dispose(); + }); + } + + public void StopMemoryChurn() + { + _memoryChurnCts?.Cancel(); + _memoryChurnCts = null; + Memory.IsChurning = false; + RefreshMemoryState("Memory churn stopped."); + NotifyChanged(); + } + + public void AddTiles(int count) + { + lock (_renderGate) + { + var targetCount = Math.Clamp(Rendering.Tiles.Count + count, 60, 480); + Rendering.Tiles = BuildTiles(targetCount, Rendering.Frame); + Rendering.Status = $"Visual tile wall now contains {targetCount} animated nodes."; + } + + NotifyChanged(); + } + + public void AddFeedItems(int count) + { + lock (_renderGate) + { + var updated = Rendering.FeedItems.ToList(); + updated.AddRange(BuildFeedItems(count)); + Rendering.FeedItems = updated; + Rendering.Status = $"Scrollable feed expanded to {updated.Count} rows."; + } + + NotifyChanged(); + } + + public void ResetRenderingScene() + { + lock (_renderGate) + { + Rendering.Frame = 0; + Rendering.Tiles = BuildTiles(168, 0); + Rendering.FeedItems = BuildFeedItems(180); + Rendering.Status = "Seeded with a heavy scroll surface and animated tile wall."; + } + + NotifyChanged(); + } + + public void ToggleRenderingAnimation() + { + if (Rendering.IsAnimating) + { + _animationTimer?.Dispose(); + _animationTimer = null; + Rendering.IsAnimating = false; + Rendering.Status = "Animation paused. Scroll the feed to inspect the dense visual tree."; + NotifyChanged(); + return; + } + + Rendering.IsAnimating = true; + Rendering.Status = "Animating the tile wall at ~14 FPS for render churn."; + _animationTimer?.Dispose(); + _animationTimer = new Timer(_ => AdvanceAnimationFrame(), null, TimeSpan.Zero, TimeSpan.FromMilliseconds(70)); + NotifyChanged(); + } + + public void BeginNetworkRun(string mode, int requestCount) + { + Network.IsRunning = true; + Network.LastMode = mode; + Network.Status = $"Running {requestCount} {mode} request(s) from the Blazor WebView..."; + NotifyChanged(); + } + + public void CompleteNetworkRun(string mode, IReadOnlyList results) + { + var recent = results + .OrderByDescending(r => r.DurationMs) + .Take(12) + .ToArray(); + + Network.IsRunning = false; + Network.LastMode = mode; + Network.SuccessCount = results.Count(r => r.Success); + Network.FailureCount = results.Count - Network.SuccessCount; + Network.AverageDurationMs = results.Count == 0 ? 0 : results.Average(r => r.DurationMs); + Network.TotalBytes = results.Sum(r => r.Bytes); + Network.RecentRequests = recent; + Network.Status = results.Count == 0 + ? "No requests were recorded." + : $"Captured {results.Count} request(s) with {Network.FailureCount} failure(s)."; + NotifyChanged(); + } + + public void FailNetworkRun(string mode, string error) + { + Network.IsRunning = false; + Network.LastMode = mode; + Network.Status = $"Network run failed before completion: {error}"; + NotifyChanged(); + } + + private void RunCpuWorker(int worker, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + while (!cancellationToken.IsCancellationRequested) + { + var score = 0d; + var primeHits = 0; + + for (var candidate = 10_000 + (worker * 137); candidate < 14_500 + (worker * 137); candidate++) + { + if (!IsPrime(candidate)) + { + continue; + } + + primeHits++; + score += Math.Sqrt(candidate) * Math.Sin(candidate / 13d); + } + + var accumulator = new double[128]; + for (var index = 0; index < accumulator.Length; index++) + { + accumulator[index] = Math.Log10(index + 2 + score) * Math.Cos(index + worker); + } + + accumulator.Sort(); + + Interlocked.Add(ref _cpuIterations, primeHits + accumulator.Length); + Cpu.IterationsCompleted = Interlocked.Read(ref _cpuIterations); + Cpu.LastBatchMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + Cpu.Status = $"Latest worker batch finished in {Cpu.LastBatchMilliseconds:F1} ms."; + stopwatch.Restart(); + + if (worker == 0) + { + NotifyChanged(); + } + } + } + + private bool IsPrime(int value) + { + if (value < 2) + { + return false; + } + + if (value % 2 == 0) + { + return value == 2; + } + + var limit = (int)Math.Sqrt(value); + for (var divisor = 3; divisor <= limit; divisor += 2) + { + if (value % divisor == 0) + { + return false; + } + } + + return true; + } + + private void AdvanceAnimationFrame() + { + lock (_renderGate) + { + var nextFrame = Rendering.Frame + 1; + Rendering.Frame = nextFrame; + Rendering.Tiles = BuildTiles(Rendering.Tiles.Count, nextFrame); + + if (nextFrame % 6 == 0) + { + Rendering.FeedItems = Rendering.FeedItems + .Select((item, index) => index < 16 + ? item with + { + Score = ((item.Score + 7 + index) % 100), + ActiveUsers = item.ActiveUsers + (index % 3) + } + : item) + .ToArray(); + } + } + + NotifyChanged(); + } + + private IReadOnlyList BuildTiles(int count, int frame) + { + var tiles = new List(count); + for (var index = 0; index < count; index++) + { + var hue = (index * 17 + frame * 5) % 360; + var scale = 0.92 + ((Math.Sin((frame + index) / 5d) + 1) * 0.18); + tiles.Add(new RenderTile( + index + 1, + $"Node {index + 1}", + hue, + scale, + 35 + ((frame * 3 + index * 11) % 65), + (index + frame) % 9 == 0)); + } + + return tiles; + } + + private IReadOnlyList BuildFeedItems(int count) + { + var items = new List(count); + for (var index = 0; index < count; index++) + { + var id = _nextFeedId++; + items.Add(new FeedItem( + id, + $"Rendering lane {id}", + id % 3 == 0 ? "Hot path" : id % 3 == 1 ? "Scroll" : "Layout", + 12 + ((id * 5) % 140), + 18 + ((id * 11) % 240), + 30 + ((id * 9) % 70), + $"Card {id} intentionally adds depth, text, and progress indicators for visual-tree analysis.")); + } + + return items; + } + + private MemoryChunk CreateChunk(int megabytes) + { + var blockCount = Math.Max(1, megabytes); + var buffers = new byte[blockCount][]; + for (var index = 0; index < blockCount; index++) + { + var buffer = new byte[1024 * 1024]; + for (var offset = 0; offset < buffer.Length; offset += 4096) + { + buffer[offset] = (byte)((index + offset) % 255); + } + + buffers[index] = buffer; + } + + var records = Enumerable.Range(0, Math.Max(12, megabytes / 2)) + .Select(index => new RetainedRecord( + $"payload-{_nextChunkId}-{index}", + string.Join(' ', Enumerable.Repeat("profiling-sample", 10 + (index % 4))), + Enumerable.Range(0, 12).Select(number => (number + index + _random.Next(0, 5)) * 1.37d).ToArray())) + .ToArray(); + + var chunk = new MemoryChunk($"chunk-{_nextChunkId++}", buffers, records); + return chunk; + } + + private void RefreshMemoryState(string status) + { + lock (_memoryGate) + { + Memory.ChunkCount = _retainedMemory.Count; + Memory.RetainedBytes = _retainedMemory.Sum(chunk => chunk.ApproximateBytes); + Memory.ManagedHeapBytes = GC.GetTotalMemory(false); + Memory.Status = status; + } + } + + private void NotifyChanged() + { + Changed?.Invoke(); + } + + public void Dispose() + { + StopCpuStress(); + StopMemoryChurn(); + _animationTimer?.Dispose(); + } + + private sealed record RetainedRecord(string Name, string Description, double[] Scores); + + private sealed class MemoryChunk + { + public MemoryChunk(string id, byte[][] buffers, RetainedRecord[] records) + { + Id = id; + Buffers = buffers; + Records = records; + ApproximateBytes = buffers.Sum(buffer => (long)buffer.Length) + + records.Sum(record => sizeof(double) * record.Scores.Length + (record.Name.Length + record.Description.Length) * sizeof(char)); + } + + public string Id { get; } + + public byte[][] Buffers { get; } + + public RetainedRecord[] Records { get; } + + public long ApproximateBytes { get; } + } +} diff --git a/src/MauiSherpa.ProfilingSample/_Imports.razor b/src/MauiSherpa.ProfilingSample/_Imports.razor new file mode 100644 index 00000000..43809f94 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Diagnostics +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MauiSherpa.ProfilingSample +@using MauiSherpa.ProfilingSample.Components +@using MauiSherpa.ProfilingSample.Models +@using MauiSherpa.ProfilingSample.Pages +@using MauiSherpa.ProfilingSample.Services diff --git a/src/MauiSherpa.ProfilingSample/wwwroot/css/app.css b/src/MauiSherpa.ProfilingSample/wwwroot/css/app.css new file mode 100644 index 00000000..65a1b678 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/wwwroot/css/app.css @@ -0,0 +1,527 @@ +:root { + color-scheme: light; + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --bg-primary: #f4f7fb; + --bg-secondary: #ffffff; + --bg-tertiary: #e8edf5; + --border-color: rgba(15, 23, 42, 0.08); + --text-primary: #0f172a; + --text-secondary: #334155; + --text-muted: #64748b; + --accent-primary: #4f46e5; + --accent-secondary: #7c3aed; + --accent-success: #0f766e; + --accent-warning: #b45309; + --accent-info: #1d4ed8; + --accent-danger: #b91c1c; + --card-shadow: 0 22px 45px rgba(15, 23, 42, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0f172a; +} + +body { + -webkit-tap-highlight-color: transparent; +} + +#app { + min-height: 100vh; +} + +button, +input { + font: inherit; +} + +.app-shell { + min-height: 100vh; + padding: + calc(28px + var(--safe-area-top)) + calc(28px + var(--safe-area-right)) + calc(28px + var(--safe-area-bottom)) + calc(28px + var(--safe-area-left)); + background: var(--bg-primary); + color: var(--text-primary); +} + +.theme-dark { + color-scheme: dark; + --bg-primary: #0f172a; + --bg-secondary: #111827; + --bg-tertiary: #1f2937; + --border-color: rgba(148, 163, 184, 0.18); + --text-primary: #e5eef8; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --accent-primary: #818cf8; + --accent-secondary: #c084fc; + --accent-success: #2dd4bf; + --accent-warning: #fbbf24; + --accent-info: #60a5fa; + --accent-danger: #f87171; + --card-shadow: 0 24px 60px rgba(2, 6, 23, 0.45); +} + +.topbar { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-start; + margin-bottom: 24px; +} + +.topbar h1, +.hero-card h2, +.scenario-card h2 { + margin: 0; + font-size: 2rem; + line-height: 1.1; +} + +.title-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.topbar-copy, +.section-copy { + margin: 10px 0 0; + color: var(--text-secondary); + max-width: 860px; + line-height: 1.6; +} + +.topbar-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + min-width: 280px; +} + +.build-note { + color: var(--text-muted); + text-align: right; + max-width: 320px; + line-height: 1.5; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: var(--accent-secondary); + font-weight: 700; +} + +.layout-body { + display: flex; + flex-direction: column; + gap: 20px; +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.hero-card, +.scenario-card, +.metric-strip { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 24px; + box-shadow: var(--card-shadow); +} + +.hero-card { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + gap: 24px; + padding: 28px; +} + +.metric-strip { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 14px; + padding: 18px; +} + +.metric-card { + background: var(--bg-tertiary); + border-radius: 18px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.metric-label, +.detail-label, +.stat-callout span, +.feed-summary { + color: var(--text-muted); + font-size: 0.82rem; +} + +.metric-card strong, +.detail-grid strong, +.stat-callout strong { + font-size: 1.2rem; +} + +.metric-detail { + color: var(--text-secondary); + font-size: 0.88rem; + line-height: 1.4; +} + +.quick-actions, +.button-row { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.btn { + border: 0; + border-radius: 999px; + padding: 12px 18px; + cursor: pointer; + transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease; +} + +.btn:hover:not(:disabled) { + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + color: white; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.scenario-card { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.scenario-card-wide { + grid-column: 1 / -1; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.status-chip { + border-radius: 999px; + padding: 8px 12px; + font-size: 0.86rem; + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.status-chip-success { + background: rgba(16, 185, 129, 0.18); + color: var(--accent-success); +} + +.status-chip-warning { + background: rgba(251, 191, 36, 0.18); + color: var(--accent-warning); +} + +.status-chip-info { + background: rgba(96, 165, 250, 0.18); + color: var(--accent-info); +} + +.status-chip-muted { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.control-row, +.detail-grid { + display: grid; + gap: 14px; +} + +.compact-grid, +.detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +label { + display: flex; + flex-direction: column; + gap: 8px; + color: var(--text-secondary); +} + +input { + padding: 12px 14px; + border-radius: 16px; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.stat-callout { + border-radius: 18px; + background: var(--bg-tertiary); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; + justify-content: center; +} + +.snapshot-list, +.request-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.snapshot-row, +.request-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 16px; + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.request-row { + align-items: center; +} + +.request-url { + display: block; + color: var(--text-muted); + margin-top: 2px; + word-break: break-all; +} + +.request-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.render-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 18px; +} + +.tile-wall { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 10px; + align-content: start; +} + +.tile { + min-height: 92px; + border-radius: 18px; + padding: 12px; + background: linear-gradient(180deg, hsla(var(--tile-hue), 82%, 62%, 0.95), hsla(calc(var(--tile-hue) + 22), 70%, 42%, 0.95)); + color: white; + transform: scale(var(--tile-scale)); + transition: transform 80ms linear; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.tile-hot { + outline: 2px solid rgba(255, 255, 255, 0.55); +} + +.tile-label { + font-size: 0.8rem; + opacity: 0.88; +} + +.feed-column { + display: flex; + flex-direction: column; + gap: 10px; +} + +.feed-list { + max-height: 520px; + overflow: auto; + padding-right: 4px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.feed-item { + background: var(--bg-tertiary); + border-radius: 18px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.feed-item-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.feed-tag { + display: inline-flex; + margin-left: 8px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(124, 58, 237, 0.12); + color: var(--accent-secondary); + font-size: 0.76rem; +} + +.feed-item p { + margin: 0; + color: var(--text-secondary); + line-height: 1.5; +} + +.feed-metrics { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; + align-items: center; + color: var(--text-secondary); +} + +.progress-bar { + height: 10px; + background: rgba(148, 163, 184, 0.2); + border-radius: 999px; + overflow: hidden; +} + +.progress-bar span { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); + border-radius: inherit; +} + +.empty-state { + display: grid; + place-items: center; + min-height: 200px; + color: var(--text-secondary); + text-align: center; +} + +.empty-state.small { + min-height: 80px; + background: var(--bg-tertiary); + border-radius: 16px; +} + +#blazor-error-ui { + display: none; + position: fixed; + left: 16px; + right: 16px; + bottom: 16px; + background: #7f1d1d; + color: white; + padding: 12px 16px; + border-radius: 16px; +} + +#blazor-error-ui .reload, +#blazor-error-ui .dismiss { + color: white; + margin-left: 12px; +} + +@media (max-width: 1200px) { + .metric-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .render-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .app-shell { + padding: + calc(18px + var(--safe-area-top)) + calc(18px + var(--safe-area-right)) + calc(18px + var(--safe-area-bottom)) + calc(18px + var(--safe-area-left)); + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .hero-card, + .topbar, + .section-header { + flex-direction: column; + } + + .metric-strip, + .compact-grid, + .detail-grid { + grid-template-columns: 1fr; + } + + .topbar-actions { + align-items: flex-start; + } + + .build-note { + text-align: left; + } +} diff --git a/src/MauiSherpa.ProfilingSample/wwwroot/index.html b/src/MauiSherpa.ProfilingSample/wwwroot/index.html new file mode 100644 index 00000000..84c1abdd --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/wwwroot/index.html @@ -0,0 +1,23 @@ + + + + + + Sherpa Profiling Sample + + + + + +
+ +
+ An unhandled error has occurred. + Reload + × +
+ + + + + diff --git a/src/MauiSherpa.ProfilingSample/wwwroot/js/profilingSample.js b/src/MauiSherpa.ProfilingSample/wwwroot/js/profilingSample.js new file mode 100644 index 00000000..646f8cf3 --- /dev/null +++ b/src/MauiSherpa.ProfilingSample/wwwroot/js/profilingSample.js @@ -0,0 +1,64 @@ +window.profilingSample = { + async runNetworkBurst(options) { + const mode = options?.mode ?? 'local'; + const requestCount = Math.max(1, Math.min(40, options?.requestCount ?? 12)); + const timestamp = Date.now(); + const requests = Array.from({ length: requestCount }, (_, index) => createRequest(mode, index, timestamp)); + const results = await Promise.all(requests.map(executeRequest)); + return results; + } +}; + +function createRequest(mode, index, timestamp) { + if (mode === 'remote-mixed') { + const remotePattern = index % 4; + if (remotePattern === 0) { + return { url: `https://jsonplaceholder.typicode.com/todos/${(index % 10) + 1}?cb=${timestamp}_${index}` }; + } + + if (remotePattern === 1) { + return { url: `https://httpbin.org/delay/1?cb=${timestamp}_${index}` }; + } + + if (remotePattern === 2) { + return { url: `https://jsonplaceholder.typicode.com/invalid-route-${index}?cb=${timestamp}_${index}` }; + } + + return { url: `https://httpbin.org/status/503?cb=${timestamp}_${index}` }; + } + + const assetPath = index % 2 === 0 ? 'css/app.css' : 'js/profilingSample.js'; + return { url: `${assetPath}?cb=${timestamp}_${index}` }; +} + +async function executeRequest(request) { + const started = performance.now(); + + try { + const response = await fetch(request.url, { + cache: 'no-store', + headers: { + 'x-sherpa-profile': 'network-burst' + } + }); + + const text = await response.text(); + return { + url: request.url, + statusCode: response.status, + success: response.ok, + durationMs: Math.round(performance.now() - started), + error: response.ok ? null : `HTTP ${response.status}`, + bytes: text.length + }; + } catch (error) { + return { + url: request.url, + statusCode: null, + success: false, + durationMs: Math.round(performance.now() - started), + error: error instanceof Error ? error.message : String(error), + bytes: 0 + }; + } +} diff --git a/src/MauiSherpa/Components/MainLayout.razor b/src/MauiSherpa/Components/MainLayout.razor index a2a14f43..0333cdfd 100644 --- a/src/MauiSherpa/Components/MainLayout.razor +++ b/src/MauiSherpa/Components/MainLayout.razor @@ -106,7 +106,11 @@ Publish - + + + + Profiling + App Inspector @@ -114,7 +118,6 @@ @if (isDebugBuild) { - Debug UI diff --git a/src/MauiSherpa/MauiProgram.cs b/src/MauiSherpa/MauiProgram.cs index df0f87b3..b8b61a3f 100644 --- a/src/MauiSherpa/MauiProgram.cs +++ b/src/MauiSherpa/MauiProgram.cs @@ -112,12 +112,18 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -223,6 +229,11 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); + builder.Services.AddSingletonAsImplementedInterfaces(); #if DEBUG #if !LINUXGTK diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor new file mode 100644 index 00000000..bda256af --- /dev/null +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -0,0 +1,1649 @@ +@page "/profiling" +@using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Models.Profiling +@using MauiSherpa.Core.Requests.Android +@using MauiSherpa.Core.Requests.Apple +@using MauiSherpa.Core.Requests.Profiling +@using Shiny.Mediator +@inject IMediator Mediator +@inject IProfilingCatalogService ProfilingCatalogService +@inject IDeviceMonitorService DeviceMonitor +@inject IDialogService DialogService +@inject IAlertService AlertService +@inject IToolbarService ToolbarService +@inject IPlatformService Platform +@inject IProcessModalService ProcessModal +@implements IDisposable + + + + +@if (catalog is null) +{ +
+ + Loading profiling catalog... +
+} +else +{ +
+ @for (int i = 0; i < WizardSteps.Length; i++) + { + var step = WizardSteps[i]; + var stepIndex = i; + var stepClass = stepIndex == currentStep ? "active" : (stepIndex < currentStep ? "completed" : "upcoming"); +
+
+ @if (stepIndex < currentStep) + { + + } + else + { + @(stepIndex + 1) + } +
+ @step.Name +
+ @if (i < WizardSteps.Length - 1) + { +
+ } + } +
+ + @if (currentStep == 0) + { +
+
+ Session setup +
+
+
+ +
+ + +
+
+ Launch planning needs a project path. Connect-to-running-app flows can omit it if you provide a process id. +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Advanced settings +
+ @if (showAdvanced) + { +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (launchMode == ProfilingCaptureLaunchMode.Attach) + { +
+ + +
+ } + +
+ +
+ } +
+
+ } + + @if (currentStep == 1) + { +
+
+ Capture kinds + +
+
+ @foreach (var captureKind in SupportedCaptureKinds) + { + + } +
+
+ } + + @if (currentStep == 2) + { +
+
+ Targets + +
+
+ @if (!string.IsNullOrWhiteSpace(CurrentPlatformNotes)) + { +
@CurrentPlatformNotes
+ } + + @if (CurrentScenarioDefinition is not null) + { +
+
@CurrentScenarioDefinition.DisplayName
+
@CurrentScenarioDefinition.Description
+
+ Suggested duration: @CurrentScenarioDefinition.SuggestedDuration.ToString(@"hh\:mm\:ss") +
+
+ } + + @if (TargetOptions.Count == 0) + { +
+ +
No targets are currently selectable for @GetPlatformDisplayName(selectedPlatform).
+ @if (selectedPlatform == ProfilingTargetPlatform.Android && knownAndroidEmulatorCount > 0) + { +
You have @knownAndroidEmulatorCount Android emulator definition(s). Start one from the Emulators page to profile it here.
+ } + @if (selectedPlatform == ProfilingTargetPlatform.iOS && knownAppleSimulatorCount > 0) + { +
You have @knownAppleSimulatorCount available Apple simulator(s). Select one after refreshing if it does not appear yet.
+ } + +
+ } + else + { +
+ @foreach (var option in TargetOptions) + { + + } +
+ } +
+
+ } + + @if (currentStep == 3) + { + @if (isCheckingPrerequisites || isPlanningCapture) + { +
+ + + @if (isCheckingPrerequisites) + { + @:Checking prerequisites... + } + else + { + @:Generating capture plan... + } + +
+ } + + @if (prerequisiteReport is not null) + { +
+
+ Prerequisites +
+ @prerequisiteReport.OkCount ok + @prerequisiteReport.WarningCount warning + @prerequisiteReport.ErrorCount error +
+
+
+
+ @foreach (var check in prerequisiteReport.Checks) + { +
+
+ @check.Name + @check.Status +
+
@check.Message
+
+ @check.Kind + @if (!string.IsNullOrWhiteSpace(check.InstalledVersion)) + { + Installed: @check.InstalledVersion + } + @if (!string.IsNullOrWhiteSpace(check.RequiredVersion)) + { + Required: @check.RequiredVersion + } +
+ @if (!string.IsNullOrWhiteSpace(check.SuggestedCommand)) + { + @check.SuggestedCommand + } +
+ } +
+
+
+ } + + @if (capturePlan is not null) + { +
+
+ Capture plan +
+ + @(capturePlan.Validation.IsValid ? "Valid" : "Needs attention") + + + @(capturePlan.CanExecute ? "Executable" : "Preview only") + +
+
+
+
+
+
Target framework
+
@capturePlan.TargetFramework
+
+
+
Output directory
+
@capturePlan.OutputDirectory
+
+
+
Command steps
+
@capturePlan.Commands.Count
+
+
+
Artifacts
+
@capturePlan.ExpectedArtifacts.Count
+
+
+ + @if (capturePlan.Validation.Errors.Count > 0) + { +
+
Errors
+
    + @foreach (var error in capturePlan.Validation.Errors) + { +
  • @error
  • + } +
+
+ } + + @if (capturePlan.Validation.Warnings.Count > 0) + { +
+
Warnings
+
    + @foreach (var warning in capturePlan.Validation.Warnings) + { +
  • @warning
  • + } +
+
+ } + + @if (capturePlan.RuntimeBindings.Count > 0) + { +
+
Runtime bindings
+
    + @foreach (var binding in capturePlan.RuntimeBindings) + { +
  • + @binding.Token — @binding.Description + @if (!string.IsNullOrWhiteSpace(binding.ExampleValue)) + { + (example: @binding.ExampleValue) + } +
  • + } +
+
+ } + +
Commands
+
+ @foreach (var step in capturePlan.Commands) + { +
+
+
+
@step.DisplayName
+
@step.Description
+
+
+ @step.Kind + @if (step.IsLongRunning) + { + Long-running + } + @if (step.RequiresManualStop) + { + Manual stop + } +
+
+ @BuildCommandLine(step) + @if (step.DependsOn?.Count > 0 || step.RequiredRuntimeBindings?.Count > 0) + { +
+ @if (step.DependsOn?.Count > 0) + { + Depends on: @string.Join(", ", step.DependsOn) + } + @if (step.RequiredRuntimeBindings?.Count > 0) + { + Needs: @string.Join(", ", step.RequiredRuntimeBindings) + } +
+ } +
+ + +
+
+ } +
+ +
Expected artifacts
+
+ @foreach (var artifact in capturePlan.ExpectedArtifacts) + { +
+
@artifact.DisplayName
+
@artifact.RelativePath
+
@artifact.Kind · @artifact.ContentType
+
+ } +
+
+
+ } + } + + @if (currentStep == 4) + { +
+
+ Capture +
+
+
+ +
Capture session running...
+
This step will be implemented in a future update.
+
+
+
+ } + + @if (currentStep == 5) + { +
+
+ Results +
+
+
+ +
Results will appear here
+
This step will be implemented in a future update.
+
+
+
+ } + +
+ @if (currentStep > 0) + { + + } +
+ @if (currentStep < TotalSteps - 1) + { + + } +
+} + +@code { + private ProfilingCatalog? catalog; + private ConnectedDevicesSnapshot snapshot = ConnectedDevicesSnapshot.Empty; + private List androidEmulators = new(); + private List appleSimulators = new(); + private List targetOptions = new(); + private HashSet selectedCaptureKinds = new(); + private ProfilingPrerequisiteReport? prerequisiteReport; + private ProfilingCapturePlan? capturePlan; + private string? selectedTargetKey; + private string? projectPath; + private string? targetFrameworkOverride; + private string? outputDirectory; + private string? processIdText; + private string configuration = "Release"; + private int diagnosticPort = 9000; + private bool suspendAtStartup = true; + private bool isRefreshingTargets; + private bool isCheckingPrerequisites; + private bool isPlanningCapture; + private int knownAndroidEmulatorCount; + private int knownAppleSimulatorCount; + private ProfilingTargetPlatform selectedPlatform = ProfilingTargetPlatform.Android; + private ProfilingScenarioKind selectedScenario = ProfilingScenarioKind.Launch; + private ProfilingCaptureLaunchMode launchMode = ProfilingCaptureLaunchMode.Launch; + + private int currentStep = 0; + private const int TotalSteps = 6; + private bool showAdvanced = false; + + private static readonly (string Name, string Icon)[] WizardSteps = new[] + { + ("Session Setup", "fa-sliders"), + ("Capture Kinds", "fa-list-check"), + ("Target", "fa-bullseye"), + ("Review & Plan", "fa-clipboard-check"), + ("Capture", "fa-circle-play"), + ("Results", "fa-chart-bar") + }; + + private bool CanProceedToNext => currentStep switch + { + 0 => launchMode == ProfilingCaptureLaunchMode.Attach || !string.IsNullOrWhiteSpace(projectPath), + 1 => selectedCaptureKinds.Count > 0, + 2 => SelectedTarget is not null, + 3 => capturePlan is not null && capturePlan.Validation.IsValid, + _ => false + }; + + private IReadOnlyList TargetOptions => targetOptions; + private TargetOption? SelectedTarget => targetOptions.FirstOrDefault(option => option.Key == selectedTargetKey); + private ProfilingPlatformCapabilities? CurrentPlatformCapabilities => catalog?.Platforms.FirstOrDefault(platform => platform.Platform == selectedPlatform); + private ProfilingScenarioDefinition? CurrentScenarioDefinition => catalog?.Scenarios.FirstOrDefault(scenario => scenario.Kind == selectedScenario); + private IReadOnlyList SupportedCaptureKinds => CurrentPlatformCapabilities?.SupportedCaptureKinds ?? Array.Empty(); + private string? CurrentPlatformNotes => CurrentPlatformCapabilities?.Notes; + + protected override async Task OnInitializedAsync() + { + ToolbarService.SetItems( + new ToolbarAction("refresh", "Refresh", "arrow.clockwise") + ); + ToolbarService.ToolbarItemClicked += OnToolbarItemClicked; + DeviceMonitor.Changed += OnDeviceMonitorChanged; + + catalog = await ProfilingCatalogService.GetCatalogAsync(); + selectedPlatform = catalog.Platforms.FirstOrDefault(platform => platform.Platform == ProfilingTargetPlatform.Android)?.Platform + ?? catalog.Platforms.First().Platform; + selectedScenario = catalog.Scenarios.FirstOrDefault(scenario => scenario.Kind == ProfilingScenarioKind.Launch)?.Kind + ?? catalog.Scenarios.First().Kind; + ResetCaptureKindsToScenarioDefaults(); + + await RefreshTargetsAsync(); + } + + private void OnToolbarItemClicked(string actionId) + { + if (actionId == "refresh") + _ = RefreshTargetsAsync(forceRefresh: true); + } + + private void OnDeviceMonitorChanged(ConnectedDevicesSnapshot updatedSnapshot) + { + snapshot = updatedSnapshot; + BuildTargetOptions(); + InvokeAsync(StateHasChanged); + } + + private async Task RefreshTargetsAsync(bool forceRefresh = false) + { + isRefreshingTargets = true; + StateHasChanged(); + + try + { + if (forceRefresh) + { + await Mediator.FlushStores("android:emulators"); + await Mediator.FlushStores("apple:simulators"); + } + + var emuTask = Mediator.Request(new GetEmulatorsRequest()); + var simTask = Mediator.Request(new GetSimulatorsRequest()); + + await Task.WhenAll(emuTask, simTask); + + var (_, emuResult) = await emuTask; + androidEmulators = emuResult?.ToList() ?? new(); + knownAndroidEmulatorCount = androidEmulators.Count; + + var (_, simResult) = await simTask; + appleSimulators = simResult?.Where(simulator => simulator.IsAvailable).ToList() ?? new(); + knownAppleSimulatorCount = appleSimulators.Count; + + snapshot = DeviceMonitor.Current; + BuildTargetOptions(); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Error refreshing profiling targets: {ex.Message}"); + } + finally + { + isRefreshingTargets = false; + StateHasChanged(); + } + } + + private Task OnPlatformChangedAsync() + { + ResetCaptureKindsToScenarioDefaults(); + BuildTargetOptions(); + prerequisiteReport = null; + capturePlan = null; + return Task.CompletedTask; + } + + private Task OnScenarioChangedAsync() + { + ResetCaptureKindsToScenarioDefaults(); + prerequisiteReport = null; + capturePlan = null; + return Task.CompletedTask; + } + + private Task OnLaunchModeChangedAsync() + { + capturePlan = null; + return Task.CompletedTask; + } + + private void ResetCaptureKindsToScenarioDefaults() + { + var defaults = CurrentScenarioDefinition?.DefaultCaptureKinds + .Where(kind => SupportedCaptureKinds.Contains(kind)) + .ToArray(); + + selectedCaptureKinds = defaults is { Length: > 0 } + ? defaults.ToHashSet() + : SupportedCaptureKinds.ToHashSet(); + } + + private void SelectTarget(string key) + { + selectedTargetKey = key; + capturePlan = null; + } + + private void OnCaptureKindChanged(ProfilingCaptureKind kind, ChangeEventArgs args) + { + var isSelected = args.Value as bool? == true; + if (isSelected) + selectedCaptureKinds.Add(kind); + else if (selectedCaptureKinds.Count > 1) + selectedCaptureKinds.Remove(kind); + + capturePlan = null; + } + + private async Task BrowseProjectAsync() + { + var selectedPath = await DialogService.PickOpenFileAsync("Select project", new[] { ".csproj" }); + if (!string.IsNullOrWhiteSpace(selectedPath)) + { + projectPath = selectedPath; + capturePlan = null; + StateHasChanged(); + } + } + + private async Task CheckPrerequisitesAsync() + { + isCheckingPrerequisites = true; + StateHasChanged(); + + try + { + var captureKinds = GetSelectedCaptureKinds(); + var (_, report) = await Mediator.Request(new GetProfilingPrerequisitesRequest(selectedPlatform, captureKinds)); + prerequisiteReport = report; + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Unable to load profiling prerequisites: {ex.Message}"); + } + finally + { + isCheckingPrerequisites = false; + StateHasChanged(); + } + } + + private async Task GeneratePlanAsync() + { + if (SelectedTarget is null) + { + await AlertService.ShowToastAsync("Select a profiling target first."); + return; + } + + isPlanningCapture = true; + StateHasChanged(); + + try + { + var definition = ProfilingCatalogService.CreateSessionDefinition( + SelectedTarget.ToProfilingTarget(), + selectedScenario, + captureKinds: GetSelectedCaptureKinds()); + + var (_, plan) = await Mediator.Request(new PlanProfilingCaptureRequest(definition, BuildPlanOptions(SelectedTarget))); + capturePlan = plan; + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Unable to generate profiling plan: {ex.Message}"); + } + finally + { + isPlanningCapture = false; + StateHasChanged(); + } + } + + private ProfilingCapturePlanOptions BuildPlanOptions(TargetOption selectedTarget) + { + var additionalBuildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (selectedTarget.Platform == ProfilingTargetPlatform.Android && + selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Emulator) + { + additionalBuildProperties["AdbTarget"] = $"-s {selectedTarget.Identifier}"; + } + + if (selectedTarget.Platform == ProfilingTargetPlatform.iOS && + selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Simulator) + { + additionalBuildProperties["_DeviceName"] = $":v2:udid={selectedTarget.Identifier}"; + } + + int? processId = null; + if (int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) + processId = parsedProcessId; + + return new ProfilingCapturePlanOptions( + ProjectPath: string.IsNullOrWhiteSpace(projectPath) ? null : projectPath, + Configuration: configuration, + TargetFramework: string.IsNullOrWhiteSpace(targetFrameworkOverride) ? null : targetFrameworkOverride, + OutputDirectory: string.IsNullOrWhiteSpace(outputDirectory) ? null : outputDirectory, + LaunchMode: launchMode, + DiagnosticPort: diagnosticPort, + SuspendAtStartup: suspendAtStartup, + ProcessId: processId, + AdditionalBuildProperties: additionalBuildProperties.Count == 0 ? null : additionalBuildProperties); + } + + private void BuildTargetOptions() + { + var options = new List(); + + switch (selectedPlatform) + { + case ProfilingTargetPlatform.Android: + options.AddRange(snapshot.AndroidDevices.Select(device => new TargetOption( + Key: $"android-device:{device.Serial}", + Platform: ProfilingTargetPlatform.Android, + Kind: ProfilingTargetKind.PhysicalDevice, + Identifier: device.Serial, + DisplayName: device.Model ?? device.Serial, + Subtitle: "Connected Android device", + KindLabel: "Device", + IsAvailable: true, + ExtraHint: device.State))); + + options.AddRange(snapshot.AndroidEmulators.Select(device => new TargetOption( + Key: $"android-emulator:{device.Serial}", + Platform: ProfilingTargetPlatform.Android, + Kind: ProfilingTargetKind.Emulator, + Identifier: device.Serial, + DisplayName: device.Model ?? device.Serial, + Subtitle: "Running Android emulator", + KindLabel: "Emulator", + IsAvailable: true, + ExtraHint: device.State))); + break; + + case ProfilingTargetPlatform.iOS: + options.AddRange(snapshot.ApplePhysicalDevices.Select(device => new TargetOption( + Key: $"ios-device:{device.Identifier}", + Platform: ProfilingTargetPlatform.iOS, + Kind: ProfilingTargetKind.PhysicalDevice, + Identifier: device.Identifier, + DisplayName: device.Name, + Subtitle: $"{device.ModelName} · iOS {device.OsVersion}", + KindLabel: "Device", + IsAvailable: device.IsAvailable, + ExtraHint: device.Interface))); + + options.AddRange(appleSimulators.Select(simulator => new TargetOption( + Key: $"ios-simulator:{simulator.Udid}", + Platform: ProfilingTargetPlatform.iOS, + Kind: ProfilingTargetKind.Simulator, + Identifier: simulator.Udid, + DisplayName: simulator.Name, + Subtitle: $"{simulator.Runtime ?? "Unknown runtime"} · {simulator.ProductFamily ?? "Simulator"}", + KindLabel: "Simulator", + IsAvailable: string.Equals(simulator.State, "Booted", StringComparison.OrdinalIgnoreCase), + ExtraHint: simulator.State))); + break; + + case ProfilingTargetPlatform.MacCatalyst: + if (Platform.IsMacCatalyst || Platform.IsMacOS) + { + options.Add(new TargetOption( + Key: "desktop:maccatalyst", + Platform: ProfilingTargetPlatform.MacCatalyst, + Kind: ProfilingTargetKind.Desktop, + Identifier: "local-maccatalyst", + DisplayName: "This Mac", + Subtitle: "Local Mac Catalyst target", + KindLabel: "Desktop", + IsAvailable: true)); + } + break; + + case ProfilingTargetPlatform.MacOS: + if (Platform.IsMacOS) + { + options.Add(new TargetOption( + Key: "desktop:macos", + Platform: ProfilingTargetPlatform.MacOS, + Kind: ProfilingTargetKind.Desktop, + Identifier: "local-macos", + DisplayName: "This Mac", + Subtitle: "Local macOS target", + KindLabel: "Desktop", + IsAvailable: true)); + } + break; + + case ProfilingTargetPlatform.Windows: + if (Platform.IsWindows) + { + options.Add(new TargetOption( + Key: "desktop:windows", + Platform: ProfilingTargetPlatform.Windows, + Kind: ProfilingTargetKind.Desktop, + Identifier: "local-windows", + DisplayName: "This PC", + Subtitle: "Local Windows target", + KindLabel: "Desktop", + IsAvailable: true)); + } + break; + } + + targetOptions = options; + if (selectedTargetKey is null || targetOptions.All(option => option.Key != selectedTargetKey)) + selectedTargetKey = targetOptions.FirstOrDefault()?.Key; + } + + private IReadOnlyList GetSelectedCaptureKinds() + => selectedCaptureKinds.Count > 0 + ? selectedCaptureKinds.OrderBy(kind => kind).ToArray() + : SupportedCaptureKinds; + + private static string GetPlatformDisplayName(ProfilingTargetPlatform platform) => platform switch + { + ProfilingTargetPlatform.iOS => "iOS", + ProfilingTargetPlatform.MacCatalyst => "Mac Catalyst", + ProfilingTargetPlatform.MacOS => "macOS", + _ => platform.ToString() + }; + + private static string GetCaptureKindDisplayName(ProfilingCaptureKind captureKind) => captureKind switch + { + ProfilingCaptureKind.Cpu => "CPU trace", + ProfilingCaptureKind.Memory => "Memory / GC dump", + ProfilingCaptureKind.Network => "Network", + ProfilingCaptureKind.Rendering => "Rendering", + ProfilingCaptureKind.Energy => "Energy", + ProfilingCaptureKind.SystemTrace => "System trace", + ProfilingCaptureKind.Logs => "Logs", + ProfilingCaptureKind.Startup => "Startup", + _ => captureKind.ToString() + }; + + private static string GetDependencyCss(DependencyStatusType status) => status switch + { + DependencyStatusType.Ok => "ok", + DependencyStatusType.Info => "ok", + DependencyStatusType.Warning => "warning", + DependencyStatusType.Error => "error", + _ => "neutral" + }; + + private bool CanRunStep(ProfilingCommandStep step) + => ResolveCommandStep(step) is not null; + + private async Task CopyCommandAsync(ProfilingCommandStep step) + { + await DialogService.CopyToClipboardAsync(BuildCommandLine(step)); + await AlertService.ShowToastAsync("Copied command to clipboard."); + } + + private async Task RunStepAsync(ProfilingCommandStep step) + { + var resolved = ResolveCommandStep(step); + if (resolved is null) + { + await AlertService.ShowToastAsync("Provide any required runtime bindings before running this command."); + return; + } + + await ProcessModal.ShowProcessAsync(resolved, requireConfirmation: true); + } + + private string BuildCommandLine(ProfilingCommandStep step) + { + var resolved = ResolveCommandStep(step); + return resolved?.CommandLine ?? step.CommandLine; + } + + private ProcessRequest? ResolveCommandStep(ProfilingCommandStep step) + { + if (step.RequiredRuntimeBindings is not null) + { + foreach (var binding in step.RequiredRuntimeBindings) + { + if (!TryResolveRuntimeBinding(binding, out _)) + return null; + } + } + + var resolvedArguments = step.Arguments.Select(ResolveRuntimeTokens).ToArray(); + var resolvedEnvironment = step.Environment?.ToDictionary( + kvp => kvp.Key, + kvp => ResolveRuntimeTokens(kvp.Value), + StringComparer.OrdinalIgnoreCase); + + return new ProcessRequest( + step.Command, + resolvedArguments, + step.WorkingDirectory, + Environment: resolvedEnvironment, + Title: step.DisplayName, + Description: step.Description); + } + + private string ResolveRuntimeTokens(string value) + { + foreach (var binding in capturePlan?.RuntimeBindings ?? Array.Empty()) + { + if (TryResolveRuntimeBinding(binding.Token, out var resolvedValue)) + value = value.Replace(binding.Token, resolvedValue, StringComparison.Ordinal); + } + + return value; + } + + private bool TryResolveRuntimeBinding(string token, out string value) + { + if (token == "{{PROCESS_ID}}" && int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) + { + value = parsedProcessId.ToString(); + return true; + } + + value = string.Empty; + return false; + } + + + private async Task NextStep() + { + if (!CanProceedToNext) return; + + if (currentStep == 2) + { + currentStep = 3; + await CheckPrerequisitesAsync(); + if (SelectedTarget is not null) + await GeneratePlanAsync(); + } + else + { + currentStep = Math.Min(currentStep + 1, TotalSteps - 1); + } + } + + private void PreviousStep() + { + currentStep = Math.Max(currentStep - 1, 0); + } + + private void GoToStep(int step) + { + if (step < currentStep) + currentStep = step; + } + + public void Dispose() + { + ToolbarService.ToolbarItemClicked -= OnToolbarItemClicked; + ToolbarService.ClearItems(); + DeviceMonitor.Changed -= OnDeviceMonitorChanged; + } + + private sealed record TargetOption( + string Key, + ProfilingTargetPlatform Platform, + ProfilingTargetKind Kind, + string Identifier, + string DisplayName, + string Subtitle, + string KindLabel, + bool IsAvailable, + string? ExtraHint = null) + { + public ProfilingTarget ToProfilingTarget() => new( + Platform, + Kind, + Identifier, + DisplayName); + } +} + + + diff --git a/src/MauiSherpa/Resources/Raw/Skills/maui-profiling/SKILL.md b/src/MauiSherpa/Resources/Raw/Skills/maui-profiling/SKILL.md new file mode 100644 index 00000000..778be7bf --- /dev/null +++ b/src/MauiSherpa/Resources/Raw/Skills/maui-profiling/SKILL.md @@ -0,0 +1,26 @@ +--- +name: maui-profiling +description: Build lightweight profiling context for a running .NET MAUI app using local MauiDevFlow status, network, and visual-tree summaries instead of uploading raw trace artifacts. Use when investigating slow screens, performance regressions, excessive network chatter, or deciding whether deeper trace capture is needed. +--- + +# MAUI Profiling Context + +Use structured, local summaries first. Avoid asking for raw `.nettrace`, `gcdump`, or other large artifacts unless the lightweight snapshot is insufficient. + +## Workflow + +1. Run `get_profiling_catalog` to understand supported scenarios and platform capabilities. +2. Run `list_profiling_targets` to discover locally running MauiDevFlow-enabled apps. +3. Run `get_profiling_snapshot` for the relevant target. +4. Review: + - runtime status (platform, device type, WebView readiness) + - recent network behavior (sample size, failures, latency, slowest requests) + - visual tree complexity (element counts, depth, top element types) +5. Use the summary to narrow the investigation before requesting any deeper traces. + +## Guidance + +- Prefer the default lightweight snapshot first. +- Increase `networkSampleSize` only when recent request volume is too low to explain the issue. +- If multiple targets are connected, specify `targetId` from `list_profiling_targets`. +- Treat the snapshot as context for follow-up recommendations, not as a full profiler replacement. diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/AnalyzeProfilingArtifactHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/AnalyzeProfilingArtifactHandlerTests.cs new file mode 100644 index 00000000..8d0d2cf0 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/AnalyzeProfilingArtifactHandlerTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using MauiSherpa.Core.Handlers.Profiling; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Moq; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Tests.Handlers.Profiling; + +public class AnalyzeProfilingArtifactHandlerTests +{ + [Fact] + public async Task Handle_ReturnsAnalysisFromService() + { + var service = new Mock(); + var context = new Mock(); + var expected = new ProfilingArtifactAnalysisResult( + new ProfilingArtifactAnalysis( + new ProfilingArtifactMetadata( + "artifact-1", + "session-1", + ProfilingArtifactKind.Trace, + "Trace capture", + "trace.json", + null, + "application/json", + DateTimeOffset.UtcNow), + "/tmp/trace.json", + true, + ProfilingAnalysisKind.Speedscope, + "summary", + [], + [], + [], + [])); + + service.Setup(x => x.AnalyzeArtifactAsync("artifact-1", It.IsAny())) + .ReturnsAsync(expected); + + var handler = new AnalyzeProfilingArtifactHandler(service.Object); + + var result = await handler.Handle(new AnalyzeProfilingArtifactRequest("artifact-1"), context.Object, CancellationToken.None); + + result.Should().Be(expected); + service.Verify(x => x.AnalyzeArtifactAsync("artifact-1", It.IsAny()), Times.Once); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs new file mode 100644 index 00000000..3b949306 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using MauiSherpa.Core.Handlers.Profiling; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Moq; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Tests.Handlers.Profiling; + +public class GetProfilingCapabilitiesHandlerTests +{ + private readonly Mock _profilingCatalogService; + private readonly Mock _context; + private readonly GetProfilingCapabilitiesHandler _handler; + + public GetProfilingCapabilitiesHandlerTests() + { + _profilingCatalogService = new Mock(); + _context = new Mock(); + _handler = new GetProfilingCapabilitiesHandler(_profilingCatalogService.Object); + } + + [Fact] + public async Task Handle_ReturnsCapabilitiesForRequestedPlatform() + { + var expectedCapabilities = new ProfilingPlatformCapabilities( + ProfilingTargetPlatform.MacCatalyst, + "Mac Catalyst", + [ProfilingTargetKind.Desktop], + [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory], + [ProfilingArtifactKind.Trace], + [ProfilingScenarioKind.Launch, ProfilingScenarioKind.Interaction], + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: true); + + _profilingCatalogService.Setup(x => x.GetCapabilitiesAsync(ProfilingTargetPlatform.MacCatalyst, It.IsAny())) + .ReturnsAsync(expectedCapabilities); + + var result = await _handler.Handle( + new GetProfilingCapabilitiesRequest(ProfilingTargetPlatform.MacCatalyst), + _context.Object, + CancellationToken.None); + + result.Should().Be(expectedCapabilities); + _profilingCatalogService.Verify(x => x.GetCapabilitiesAsync(ProfilingTargetPlatform.MacCatalyst, It.IsAny()), Times.Once); + } + + [Fact] + public void GetKey_ReturnsPlatformSpecificKey() + { + var request = new GetProfilingCapabilitiesRequest(ProfilingTargetPlatform.Windows); + + request.GetKey().Should().Be("profiling:capabilities:Windows"); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs new file mode 100644 index 00000000..449378d6 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using MauiSherpa.Core.Handlers.Profiling; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Moq; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Tests.Handlers.Profiling; + +public class GetProfilingCatalogHandlerTests +{ + private readonly Mock _profilingCatalogService; + private readonly Mock _context; + private readonly GetProfilingCatalogHandler _handler; + + public GetProfilingCatalogHandlerTests() + { + _profilingCatalogService = new Mock(); + _context = new Mock(); + _handler = new GetProfilingCatalogHandler(_profilingCatalogService.Object); + } + + [Fact] + public async Task Handle_ReturnsCatalogFromService() + { + var expectedCatalog = new ProfilingCatalog( + [new ProfilingPlatformCapabilities( + ProfilingTargetPlatform.Android, + "Android", + [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Emulator], + [ProfilingCaptureKind.Cpu], + [ProfilingArtifactKind.Trace], + [ProfilingScenarioKind.Launch], + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: false)], + [new ProfilingScenarioDefinition( + ProfilingScenarioKind.Launch, + "Launch & startup", + "desc", + [ProfilingCaptureKind.Cpu], + TimeSpan.FromMinutes(1))]); + + _profilingCatalogService.Setup(x => x.GetCatalogAsync(It.IsAny())) + .ReturnsAsync(expectedCatalog); + + var result = await _handler.Handle(new GetProfilingCatalogRequest(), _context.Object, CancellationToken.None); + + result.Should().Be(expectedCatalog); + _profilingCatalogService.Verify(x => x.GetCatalogAsync(It.IsAny()), Times.Once); + } + + [Fact] + public void GetKey_ReturnsStableCatalogKey() + { + var request = new GetProfilingCatalogRequest(); + + request.GetKey().Should().Be("profiling:catalog"); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingPrerequisitesHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingPrerequisitesHandlerTests.cs new file mode 100644 index 00000000..a855e6e1 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingPrerequisitesHandlerTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using MauiSherpa.Core.Handlers.Profiling; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Moq; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Tests.Handlers.Profiling; + +public class GetProfilingPrerequisitesHandlerTests +{ + private readonly Mock _profilingPrerequisitesService = new(); + private readonly Mock _context = new(); + private readonly GetProfilingPrerequisitesHandler _handler; + + public GetProfilingPrerequisitesHandlerTests() + { + _handler = new GetProfilingPrerequisitesHandler(_profilingPrerequisitesService.Object); + } + + [Fact] + public async Task Handle_ReturnsPrerequisiteReport() + { + var requestedCaptureKinds = new[] { ProfilingCaptureKind.Cpu }; + var expectedReport = new ProfilingPrerequisiteReport( + new ProfilingPrerequisiteContext( + ProfilingTargetPlatform.Android, + requestedCaptureKinds, + "/tmp", + "/usr/local/share/dotnet/dotnet", + new DoctorContext("/tmp", "/usr/local/share/dotnet", null, null, null, "10.0.100")), + [ + new ProfilingPrerequisiteStatus( + "dotnet-trace", + ProfilingPrerequisiteKind.DotNetTool, + DependencyStatusType.Ok, + IsRequired: true, + RequiredVersion: "10.x", + RecommendedVersion: "10.x", + InstalledVersion: "10.0.41001", + Message: "Ready") + ], + DateTimeOffset.UtcNow); + + _profilingPrerequisitesService + .Setup(x => x.GetPrerequisitesAsync( + ProfilingTargetPlatform.Android, + It.Is>(kinds => kinds.SequenceEqual(requestedCaptureKinds)), + null, + It.IsAny())) + .ReturnsAsync(expectedReport); + + var result = await _handler.Handle( + new GetProfilingPrerequisitesRequest(ProfilingTargetPlatform.Android, requestedCaptureKinds), + _context.Object, + CancellationToken.None); + + result.Should().Be(expectedReport); + } + + [Fact] + public void GetKey_ReturnsStablePlatformAndCaptureKey() + { + var request = new GetProfilingPrerequisitesRequest( + ProfilingTargetPlatform.Android, + [ProfilingCaptureKind.Memory, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory]); + + request.GetKey().Should().Be("profiling:prerequisites:Android:Cpu,Memory"); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/PlanProfilingCaptureHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/PlanProfilingCaptureHandlerTests.cs new file mode 100644 index 00000000..e5e108dc --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/PlanProfilingCaptureHandlerTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using MauiSherpa.Core.Handlers.Profiling; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Requests.Profiling; +using Moq; +using Shiny.Mediator; + +namespace MauiSherpa.Core.Tests.Handlers.Profiling; + +public class PlanProfilingCaptureHandlerTests +{ + private readonly Mock _profilingCaptureOrchestrationService = new(); + private readonly Mock _context = new(); + + [Fact] + public async Task Handle_ReturnsPlanFromOrchestrationService() + { + var session = new ProfilingSessionDefinition( + "session-1", + "Android launch", + new ProfilingTarget( + ProfilingTargetPlatform.Android, + ProfilingTargetKind.Emulator, + "emulator-5554", + "Pixel 8"), + ProfilingScenarioKind.Launch, + [ProfilingCaptureKind.Cpu], + CreatedAt: DateTimeOffset.UtcNow); + var request = new PlanProfilingCaptureRequest(session, new ProfilingCapturePlanOptions(ProjectPath: "/Users/test/App.csproj")); + var expectedPlan = new ProfilingCapturePlan( + session, + new ProfilingPlatformCapabilities( + ProfilingTargetPlatform.Android, + "Android", + [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Emulator], + [ProfilingCaptureKind.Cpu], + [ProfilingArtifactKind.Trace], + [ProfilingScenarioKind.Launch], + SupportsLaunchProfiling: true, + SupportsAttachToProcess: true, + SupportsLiveMetrics: true, + SupportsSymbolication: false), + request.Options!, + "macOS", + "net10.0-android", + "artifacts/profiling/session-1", + "/Users/test", + true, + null, + null, + new ProfilingPlanValidation([], []), + [], + [], + [], + new Dictionary()); + + _profilingCaptureOrchestrationService.Setup(service => + service.PlanCaptureAsync(session, request.Options, It.IsAny())) + .ReturnsAsync(expectedPlan); + + var handler = new PlanProfilingCaptureHandler(_profilingCaptureOrchestrationService.Object); + var result = await handler.Handle(request, _context.Object, CancellationToken.None); + + result.Should().Be(expectedPlan); + _profilingCaptureOrchestrationService.Verify(service => + service.PlanCaptureAsync(session, request.Options, It.IsAny()), Times.Once); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs new file mode 100644 index 00000000..ca834aa4 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using Moq; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class CopilotToolsServiceTests +{ + [Fact] + public void Constructor_RegistersProfilingToolsAsReadOnly() + { + var sut = new CopilotToolsService( + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); + + sut.GetTool("get_profiling_catalog").Should().NotBeNull(); + sut.GetTool("list_profiling_targets").Should().NotBeNull(); + sut.GetTool("list_profiling_artifacts").Should().NotBeNull(); + sut.GetTool("get_profiling_snapshot").Should().NotBeNull(); + sut.GetTool("analyze_profiling_artifact").Should().NotBeNull(); + sut.ReadOnlyToolNames.Should().Contain(new[] { "get_profiling_catalog", "list_profiling_targets", "list_profiling_artifacts", "get_profiling_snapshot", "analyze_profiling_artifact" }); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactAnalysisServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactAnalysisServiceTests.cs new file mode 100644 index 00000000..a7ff29a0 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactAnalysisServiceTests.cs @@ -0,0 +1,221 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using Moq; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingArtifactAnalysisServiceTests : IDisposable +{ + private readonly string _libraryRoot; + private readonly string _externalRoot; + private readonly InMemoryEncryptedSettingsService _settingsService = new(); + private readonly Mock _logger = new(); + private readonly ProfilingArtifactLibraryService _artifactLibraryService; + private readonly ProfilingArtifactAnalysisService _analysisService; + + public ProfilingArtifactAnalysisServiceTests() + { + _libraryRoot = Path.Combine(Path.GetTempPath(), $"maui-sherpa-profiling-analysis-library-{Guid.NewGuid():N}"); + _externalRoot = Path.Combine(Path.GetTempPath(), $"maui-sherpa-profiling-analysis-external-{Guid.NewGuid():N}"); + _artifactLibraryService = new ProfilingArtifactLibraryService(_settingsService, _logger.Object, _libraryRoot); + _analysisService = new ProfilingArtifactAnalysisService(_artifactLibraryService, _logger.Object); + } + + [Fact] + public async Task AnalyzeArtifactAsync_SpeedscopeTrace_ReturnsHotspotSummary() + { + var tracePath = CreateExternalFile("session-1-trace.speedscope.json", """ + { + "$schema": "https://www.speedscope.app/file-format-schema.json", + "shared": { + "frames": [ + { "name": "RenderFrame", "file": "Render.cs", "line": 42 }, + { "name": "LayoutCycle", "file": "Layout.cs", "line": 15 } + ] + }, + "profiles": [ + { + "type": "sampled", + "name": "UI Thread", + "unit": "milliseconds", + "startValue": 0, + "endValue": 100, + "samples": [[0], [0], [1], [0]], + "weights": [40, 30, 10, 20] + } + ] + } + """); + + await SaveArtifactAsync( + id: "session-1-trace", + sessionId: "session-1", + kind: ProfilingArtifactKind.Trace, + fileName: "session-1-trace.speedscope.json", + artifactPath: tracePath, + contentType: "application/json"); + + var result = await _analysisService.AnalyzeArtifactAsync("session-1-trace"); + + result.Analysis.Should().NotBeNull(); + result.Analysis!.Kind.Should().Be(ProfilingAnalysisKind.Speedscope); + result.Analysis.Summary.Should().Contain("RenderFrame"); + result.Analysis.Hotspots.Should().NotBeEmpty(); + result.Analysis.Hotspots[0].Name.Should().Be("RenderFrame"); + result.Analysis.Hotspots[0].PercentOfTrace.Should().BeApproximately(90, 0.1); + result.Analysis.Metrics.Should().Contain(metric => metric.Key == "durationMs" && metric.NumericValue == 100); + result.Analysis.Insights.Should().Contain(insight => insight.Title == "Single hotspot dominates the trace"); + } + + [Fact] + public async Task AnalyzeArtifactAsync_LogArtifact_ReturnsCountsAndRecurringHotspots() + { + var logPath = CreateExternalFile("session-2-logs.txt", """ + 2025-01-01T12:00:00Z INFO Starting capture + 2025-01-01T12:00:01Z WARN Slow request to /api/items + 2025-01-01T12:00:02Z ERROR Timeout talking to backend + 2025-01-01T12:00:03Z ERROR Timeout talking to backend + 2025-01-01T12:00:04Z INFO Capture complete + """); + + await SaveArtifactAsync( + id: "session-2-logs", + sessionId: "session-2", + kind: ProfilingArtifactKind.Logs, + fileName: "session-2-logs.txt", + artifactPath: logPath, + contentType: "text/plain"); + + var result = await _analysisService.AnalyzeArtifactAsync("session-2-logs"); + + result.Analysis.Should().NotBeNull(); + result.Analysis!.Kind.Should().Be(ProfilingAnalysisKind.Logs); + result.Analysis.Summary.Should().Contain("2 error"); + result.Analysis.Hotspots.Should().ContainSingle(); + result.Analysis.Hotspots[0].Name.Should().Be("ERROR Timeout talking to backend"); + result.Analysis.Metrics.Should().Contain(metric => metric.Key == "warningCount" && metric.NumericValue == 1); + result.Analysis.Metrics.Should().Contain(metric => metric.Key == "errorCount" && metric.NumericValue == 2); + result.Analysis.Insights.Should().Contain(insight => insight.Title == "Errors detected in captured logs"); + } + + [Fact] + public async Task AnalyzeArtifactAsync_UnsupportedExport_FallsBackToMetadataSummary() + { + var exportPath = CreateExternalFile("session-3-memory.gcdump", "gcdump-binary-placeholder"); + + await SaveArtifactAsync( + id: "session-3-memory", + sessionId: "session-3", + kind: ProfilingArtifactKind.Export, + fileName: "session-3-memory.gcdump", + artifactPath: exportPath, + contentType: "application/octet-stream"); + + var result = await _analysisService.AnalyzeArtifactAsync("session-3-memory"); + + result.Analysis.Should().NotBeNull(); + result.Analysis!.Kind.Should().Be(ProfilingAnalysisKind.Metadata); + result.Analysis.Summary.Should().Contain("metadata-only analysis"); + result.Analysis.Notes.Should().Contain(note => note.Contains("specialized tool", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task AnalyzeArtifactsAsync_UsesArtifactLibraryQuery() + { + var tracePath = CreateExternalFile("session-4-trace.json", "{ \"value\": 1 }"); + var logPath = CreateExternalFile("session-5-log.txt", "2025-01-01T00:00:00Z INFO Hello"); + + await SaveArtifactAsync("session-4-trace", "session-4", ProfilingArtifactKind.Report, "session-4-trace.json", tracePath, "application/json"); + await SaveArtifactAsync("session-5-log", "session-5", ProfilingArtifactKind.Logs, "session-5-log.txt", logPath, "text/plain"); + + var results = await _analysisService.AnalyzeArtifactsAsync(new ProfilingArtifactLibraryQuery(SessionId: "session-4")); + + results.Should().ContainSingle(); + results[0].Artifact.Id.Should().Be("session-4-trace"); + results[0].Kind.Should().Be(ProfilingAnalysisKind.Json); + } + + public void Dispose() + { + if (Directory.Exists(_libraryRoot)) + { + Directory.Delete(_libraryRoot, recursive: true); + } + + if (Directory.Exists(_externalRoot)) + { + Directory.Delete(_externalRoot, recursive: true); + } + } + + private async Task SaveArtifactAsync( + string id, + string sessionId, + ProfilingArtifactKind kind, + string fileName, + string artifactPath, + string contentType) + { + await _artifactLibraryService.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + CreateMetadata(id, sessionId, kind, fileName, contentType), + ArtifactPath: artifactPath)); + } + + private string CreateExternalFile(string relativePath, string contents) + { + var path = Path.Combine(_externalRoot, relativePath); + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(path, contents); + return path; + } + + private static ProfilingArtifactMetadata CreateMetadata( + string id, + string sessionId, + ProfilingArtifactKind kind, + string fileName, + string contentType) => + new( + Id: id, + SessionId: sessionId, + Kind: kind, + DisplayName: fileName, + FileName: fileName, + RelativePath: null, + ContentType: contentType, + CreatedAt: DateTimeOffset.UtcNow, + Properties: new Dictionary + { + ["targetPlatform"] = "MacCatalyst", + ["scenario"] = "Launch", + ["category"] = kind.ToString() + }); + + private sealed class InMemoryEncryptedSettingsService : IEncryptedSettingsService + { + public MauiSherpaSettings Current { get; private set; } = new(); + + public event Action? OnSettingsChanged; + + public Task GetSettingsAsync() => Task.FromResult(Current); + + public Task SaveSettingsAsync(MauiSherpaSettings settings) + { + Current = settings; + OnSettingsChanged?.Invoke(); + return Task.CompletedTask; + } + + public Task UpdateSettingsAsync(Func transform) => + SaveSettingsAsync(transform(Current)); + + public Task SettingsExistAsync() => Task.FromResult(true); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactLibraryServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactLibraryServiceTests.cs new file mode 100644 index 00000000..bfa100a2 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactLibraryServiceTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using Moq; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingArtifactLibraryServiceTests : IDisposable +{ + private readonly string _testRoot; + private readonly string _externalRoot; + private readonly InMemoryEncryptedSettingsService _settingsService = new(); + private readonly Mock _logger = new(); + private readonly ProfilingArtifactLibraryService _service; + + public ProfilingArtifactLibraryServiceTests() + { + _testRoot = Path.Combine(Path.GetTempPath(), $"maui-sherpa-profiling-artifacts-{Guid.NewGuid()}"); + _externalRoot = Path.Combine(Path.GetTempPath(), $"maui-sherpa-profiling-external-{Guid.NewGuid()}"); + _service = new ProfilingArtifactLibraryService(_settingsService, _logger.Object, _testRoot); + } + + [Fact] + public async Task SaveArtifactAsync_PersistsArtifactMetadataAndResolvesExternalPath() + { + var externalFile = CreateExternalFile("captures/session-1-trace.speedscope.json", "trace"); + var metadata = CreateMetadata("session-1-trace", "session-1", ProfilingArtifactKind.Trace, "session-1-trace.speedscope.json"); + + var saved = await _service.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + Metadata: metadata, + ArtifactPath: externalFile)); + + saved.IsManagedPath.Should().BeFalse(); + saved.Metadata.RelativePath.Should().Be(Path.GetFullPath(externalFile)); + saved.Metadata.SizeBytes.Should().Be(new FileInfo(externalFile).Length); + saved.SourcePath.Should().BeNull(); + + var artifacts = await _service.GetArtifactsAsync(); + artifacts.Should().ContainSingle() + .Which.Metadata.Id.Should().Be(metadata.Id); + + var resolvedPath = await _service.GetArtifactPathAsync(metadata.Id); + resolvedPath.Should().Be(Path.GetFullPath(externalFile)); + _settingsService.Current.ProfilingArtifacts.Should().ContainSingle(); + } + + [Fact] + public async Task SaveArtifactAsync_CopyToLibrary_CreatesManagedCopy() + { + var externalFile = CreateExternalFile("imports/session-2-logs.txt", "hello logs"); + var metadata = CreateMetadata("session-2-logs", "session-2", ProfilingArtifactKind.Logs, "session-2-logs.txt"); + + var saved = await _service.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + Metadata: metadata, + ArtifactPath: externalFile, + CopyToLibrary: true)); + + saved.IsManagedPath.Should().BeTrue(); + saved.SourcePath.Should().Be(Path.GetFullPath(externalFile)); + Path.IsPathRooted(saved.Metadata.RelativePath).Should().BeFalse(); + + var managedPath = await _service.GetArtifactPathAsync(metadata.Id); + managedPath.Should().NotBeNull(); + File.Exists(managedPath!).Should().BeTrue(); + File.ReadAllText(managedPath!).Should().Be("hello logs"); + } + + [Fact] + public async Task SaveArtifactAsync_UpdatesExistingArtifactWithoutDuplicatingEntry() + { + var externalFile = CreateExternalFile("captures/session-3-report.json", "{}"); + var original = await _service.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + Metadata: CreateMetadata("session-3-report", "session-3", ProfilingArtifactKind.Report, "session-3-report.json"), + ArtifactPath: externalFile)); + + var updatedMetadata = original.Metadata with + { + DisplayName = "Updated report", + Properties = new Dictionary { ["scenario"] = "launch" } + }; + + var updated = await _service.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + Metadata: updatedMetadata, + ArtifactPath: externalFile)); + + updated.AddedAt.Should().Be(original.AddedAt); + updated.UpdatedAt.Should().BeOnOrAfter(original.UpdatedAt); + updated.Metadata.DisplayName.Should().Be("Updated report"); + updated.Metadata.Properties.Should().ContainKey("scenario").WhoseValue.Should().Be("launch"); + + var artifacts = await _service.GetArtifactsAsync(); + artifacts.Should().ContainSingle(); + } + + [Fact] + public async Task DeleteArtifactAsync_RemovesMetadataAndManagedFile() + { + var externalFile = CreateExternalFile("imports/session-4-memory.gcdump", "gcdump"); + var saved = await _service.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + Metadata: CreateMetadata("session-4-memory", "session-4", ProfilingArtifactKind.Metrics, "session-4-memory.gcdump"), + ArtifactPath: externalFile, + CopyToLibrary: true)); + var managedPath = await _service.GetArtifactPathAsync(saved.Metadata.Id); + + await _service.DeleteArtifactAsync(saved.Metadata.Id, deleteFile: true); + + (await _service.GetArtifactsAsync()).Should().BeEmpty(); + File.Exists(managedPath!).Should().BeFalse(); + } + + [Fact] + public async Task GetArtifactsAsync_FiltersBySessionKindAndExistingFiles() + { + var existingTrace = CreateExternalFile("captures/session-5-trace.speedscope.json", "trace"); + var missingLogs = Path.Combine(_externalRoot, "missing", "session-6-logs.txt"); + + await _service.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + Metadata: CreateMetadata("session-5-trace", "session-5", ProfilingArtifactKind.Trace, "session-5-trace.speedscope.json"), + ArtifactPath: existingTrace)); + + await _service.SaveArtifactAsync(new ProfilingArtifactLibrarySaveRequest( + Metadata: CreateMetadata("session-6-logs", "session-6", ProfilingArtifactKind.Logs, "session-6-logs.txt"), + ArtifactPath: missingLogs)); + + var bySession = await _service.GetArtifactsAsync(new ProfilingArtifactLibraryQuery(SessionId: "session-5")); + bySession.Should().ContainSingle().Which.Metadata.Kind.Should().Be(ProfilingArtifactKind.Trace); + + var existingOnly = await _service.GetArtifactsAsync(new ProfilingArtifactLibraryQuery(Kind: ProfilingArtifactKind.Logs, IncludeMissing: false)); + existingOnly.Should().BeEmpty(); + } + + [Fact] + public void GetDefaultArtifactDirectory_ReturnsSessionScopedLibraryPath() + { + var path = _service.GetDefaultArtifactDirectory("session-7"); + + path.Should().Be(Path.Combine(_testRoot, "session-7")); + } + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + + if (Directory.Exists(_externalRoot)) + { + Directory.Delete(_externalRoot, recursive: true); + } + } + + private string CreateFile(string relativePath, string contents) + => CreateFile(_testRoot, relativePath, contents); + + private string CreateExternalFile(string relativePath, string contents) + => CreateFile(_externalRoot, relativePath, contents); + + private static string CreateFile(string root, string relativePath, string contents) + { + var fullPath = Path.Combine(root, relativePath); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(fullPath, contents); + return fullPath; + } + + private static ProfilingArtifactMetadata CreateMetadata( + string id, + string sessionId, + ProfilingArtifactKind kind, + string fileName) => + new( + Id: id, + SessionId: sessionId, + Kind: kind, + DisplayName: fileName, + FileName: fileName, + RelativePath: null, + ContentType: "application/octet-stream", + CreatedAt: DateTimeOffset.UtcNow); + + private sealed class InMemoryEncryptedSettingsService : IEncryptedSettingsService + { + public MauiSherpaSettings Current { get; private set; } = new(); + + public event Action? OnSettingsChanged; + + public Task GetSettingsAsync() => Task.FromResult(Current); + + public Task SaveSettingsAsync(MauiSherpaSettings settings) + { + Current = settings; + OnSettingsChanged?.Invoke(); + return Task.CompletedTask; + } + + public Task UpdateSettingsAsync(Func transform) => + SaveSettingsAsync(transform(Current)); + + public Task SettingsExistAsync() => Task.FromResult(true); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs new file mode 100644 index 00000000..9442ef93 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs @@ -0,0 +1,224 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using Moq; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingCaptureOrchestrationServiceTests +{ + private readonly ProfilingCatalogService _catalogService = new([]); + private readonly Mock _prerequisitesService = new(); + private readonly Mock _deviceMonitorService = new(); + private readonly Mock _platformService = new(); + private readonly Mock _androidSdkSettingsService = new(); + private readonly Mock _loggingService = new(); + + public ProfilingCaptureOrchestrationServiceTests() + { + _platformService.SetupGet(x => x.PlatformName).Returns("macOS"); + _platformService.SetupGet(x => x.IsMacOS).Returns(true); + _platformService.SetupGet(x => x.IsMacCatalyst).Returns(false); + _platformService.SetupGet(x => x.IsWindows).Returns(false); + _platformService.SetupGet(x => x.IsLinux).Returns(false); + _deviceMonitorService.SetupGet(x => x.Current).Returns(ConnectedDevicesSnapshot.Empty); + _androidSdkSettingsService.Setup(x => x.GetEffectiveSdkPathAsync()) + .ReturnsAsync("/Users/test/Library/Android/sdk"); + _prerequisitesService.Setup(x => x.GetPrerequisitesAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((ProfilingTargetPlatform platform, IReadOnlyList? captureKinds, string? _, CancellationToken _) => + CreateReadyPrerequisites(platform, captureKinds ?? [])); + } + + [Fact] + public async Task PlanCaptureAsync_AndroidEmulatorLaunch_UsesDsRouterServerServerAndDiagnosticBuildProperties() + { + var snapshot = ConnectedDevicesSnapshot.Empty with + { + AndroidEmulators = [new DeviceInfo("emulator-5554", "device", "Pixel 8", true)] + }; + _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); + + var service = CreateService(); + var session = _catalogService.CreateSessionDefinition( + new ProfilingTarget( + ProfilingTargetPlatform.Android, + ProfilingTargetKind.Emulator, + "emulator-5554", + "Pixel 8"), + ProfilingScenarioKind.Launch, + appId: "com.example.app"); + + var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( + ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); + + plan.Validation.IsValid.Should().BeTrue(); + plan.CanExecute.Should().BeTrue(); + plan.Diagnostics.Should().NotBeNull(); + plan.Diagnostics!.DsRouterMode.Should().Be(ProfilingDsRouterMode.ServerServer); + plan.Diagnostics.Address.Should().Be("10.0.2.2"); + plan.IsTargetCurrentlyAvailable.Should().BeTrue(); + + // Modern flow: no standalone dsrouter step — trace/gcdump use --dsrouter inline + plan.Commands.Select(command => command.Id).Should().ContainInOrder( + "capture-trace", + "build-and-run", + "capture-memory"); + plan.Commands.Should().NotContain(command => command.Id == "start-dsrouter"); + + var buildStep = plan.Commands.Single(command => command.Id == "build-and-run"); + buildStep.CommandLine.Should().Contain("-p:DiagnosticAddress=10.0.2.2"); + buildStep.CommandLine.Should().Contain("-p:DiagnosticListenMode=connect"); + buildStep.CommandLine.Should().Contain("-f net10.0-android"); + buildStep.CanRunParallel.Should().BeTrue(); + buildStep.StopTrigger.Should().Be(ProfilingStopTrigger.OnPipelineStop); + + var traceStep = plan.Commands.Single(command => command.Id == "capture-trace"); + traceStep.CommandLine.Should().Contain("--dsrouter android-emu"); + traceStep.CommandLine.Should().NotContain("--diagnostic-port"); + traceStep.CanRunParallel.Should().BeTrue(); + traceStep.StopTrigger.Should().Be(ProfilingStopTrigger.ManualStop); + } + + [Fact] + public async Task PlanCaptureAsync_IosPhysicalDeviceLaunch_UsesDsRouterServerClientAndListenMode() + { + var snapshot = ConnectedDevicesSnapshot.Empty with + { + ApplePhysicalDevices = + [ + new AppleDeviceInfo("00008110-000E64C20A91801E", "Test iPhone", "iPhone 15", "iOS", "arm64", "17.5", false, true, "usb", null) + ] + }; + _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); + + var service = CreateService(); + var session = _catalogService.CreateSessionDefinition( + new ProfilingTarget( + ProfilingTargetPlatform.iOS, + ProfilingTargetKind.PhysicalDevice, + "00008110-000E64C20A91801E", + "Test iPhone"), + ProfilingScenarioKind.Launch, + appId: "com.example.iosapp"); + + var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( + ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); + + plan.Validation.IsValid.Should().BeTrue(); + plan.CanExecute.Should().BeTrue(); + plan.Diagnostics.Should().NotBeNull(); + plan.Diagnostics!.DsRouterMode.Should().Be(ProfilingDsRouterMode.ServerClient); + plan.Diagnostics.ListenMode.Should().Be(ProfilingDiagnosticListenMode.Listen); + + // Modern flow: no standalone dsrouter step — trace/gcdump use --dsrouter inline + plan.Commands.Select(command => command.Id).Should().ContainInOrder( + "capture-trace", + "build-and-run", + "capture-memory"); + plan.Commands.Should().NotContain(command => command.Id == "start-dsrouter"); + + var traceStep = plan.Commands.Single(command => command.Id == "capture-trace"); + traceStep.CommandLine.Should().Contain("--dsrouter ios"); + traceStep.CommandLine.Should().NotContain("--diagnostic-port"); + + var buildStep = plan.Commands.Single(command => command.Id == "build-and-run"); + buildStep.CommandLine.Should().Contain("-f net10.0-ios"); + buildStep.CommandLine.Should().Contain("-p:DiagnosticListenMode=listen"); + } + + [Fact] + public async Task PlanCaptureAsync_MacCatalystLaunch_AddsRuntimeBindingForProcessAttach() + { + var service = CreateService(); + var session = new ProfilingSessionDefinition( + "session-1", + "Mac Catalyst Trace", + new ProfilingTarget( + ProfilingTargetPlatform.MacCatalyst, + ProfilingTargetKind.Desktop, + "local-desktop", + "MAUI Sherpa"), + ProfilingScenarioKind.Interaction, + [ProfilingCaptureKind.Cpu], + AppId: "codes.redth.mauisherpa", + Duration: TimeSpan.FromMinutes(5), + CreatedAt: DateTimeOffset.UtcNow); + + var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( + ProjectPath: "/Users/test/src/MauiSherpa/MauiSherpa.csproj")); + + plan.Validation.IsValid.Should().BeTrue(); + plan.Diagnostics.Should().BeNull(); + plan.RequiresRuntimeInputs.Should().BeTrue(); + plan.CanExecute.Should().BeFalse(); + plan.RuntimeBindings.Should().ContainSingle(binding => binding.Token == "{{PROCESS_ID}}"); + plan.Commands.Select(command => command.Id).Should().ContainInOrder( + "build-and-run", + "discover-process-id", + "capture-trace"); + } + + [Fact] + public async Task PlanCaptureAsync_LaunchWithoutProjectPath_ReturnsValidationError() + { + var service = CreateService(); + var session = _catalogService.CreateSessionDefinition( + new ProfilingTarget( + ProfilingTargetPlatform.Android, + ProfilingTargetKind.PhysicalDevice, + "device-01", + "Pixel 9"), + ProfilingScenarioKind.Launch); + + var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions()); + + plan.Validation.IsValid.Should().BeFalse(); + plan.Validation.Errors.Should().Contain(error => + error.Contains("project path", StringComparison.OrdinalIgnoreCase)); + } + + private ProfilingCaptureOrchestrationService CreateService() => + new( + _catalogService, + _prerequisitesService.Object, + _deviceMonitorService.Object, + _platformService.Object, + _androidSdkSettingsService.Object, + _loggingService.Object); + + private static ProfilingPrerequisiteReport CreateReadyPrerequisites( + ProfilingTargetPlatform platform, + IReadOnlyList captureKinds) + { + return new ProfilingPrerequisiteReport( + new ProfilingPrerequisiteContext( + platform, + captureKinds, + "/Users/test/code/MAUI.Sherpa", + "/usr/local/share/dotnet/dotnet", + new DoctorContext( + "/Users/test/code/MAUI.Sherpa", + "/usr/local/share/dotnet", + "/Users/test/code/MAUI.Sherpa/global.json", + "10.0.100", + null, + "10.0.100", + ActiveSdkVersion: "10.0.103", + ResolvedSdkVersion: "10.0.103")), + [new ProfilingPrerequisiteStatus( + "Host Platform", + ProfilingPrerequisiteKind.HostPlatform, + DependencyStatusType.Ok, + IsRequired: true, + RequiredVersion: null, + RecommendedVersion: null, + InstalledVersion: "macOS", + Message: "Host ready")], + DateTimeOffset.UtcNow); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs new file mode 100644 index 00000000..37a15cc6 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using Moq; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingCatalogServiceTests +{ + [Fact] + public async Task GetCatalogAsync_ReturnsBuiltInPlatformsAndScenarios() + { + var service = new ProfilingCatalogService([]); + + var result = await service.GetCatalogAsync(); + + result.Platforms.Should().HaveCount(5); + result.Scenarios.Should().Contain(x => x.Kind == ProfilingScenarioKind.Launch); + result.Platforms.Should().Contain(x => + x.Platform == ProfilingTargetPlatform.Android && + x.SupportedTargetKinds.Contains(ProfilingTargetKind.Emulator)); + } + + [Fact] + public async Task GetCapabilitiesAsync_UsesRegisteredProviderOverride() + { + var customCapabilities = new ProfilingPlatformCapabilities( + ProfilingTargetPlatform.Android, + "Android (custom)", + [ProfilingTargetKind.PhysicalDevice], + [ProfilingCaptureKind.Cpu], + [ProfilingArtifactKind.Trace], + [ProfilingScenarioKind.Launch], + SupportsLaunchProfiling: true, + SupportsAttachToProcess: false, + SupportsLiveMetrics: false, + SupportsSymbolication: false, + Notes: "Custom override"); + + var provider = new Mock(); + provider.SetupGet(x => x.Platform).Returns(ProfilingTargetPlatform.Android); + provider.Setup(x => x.GetCapabilitiesAsync(It.IsAny())) + .ReturnsAsync(customCapabilities); + + var service = new ProfilingCatalogService([provider.Object]); + + var result = await service.GetCapabilitiesAsync(ProfilingTargetPlatform.Android); + + result.Should().Be(customCapabilities); + } + + [Fact] + public void CreateSessionDefinition_UsesScenarioDefaultsWhenCaptureKindsNotProvided() + { + var service = new ProfilingCatalogService([]); + var target = new ProfilingTarget( + ProfilingTargetPlatform.Android, + ProfilingTargetKind.Emulator, + "emulator-5554", + "Pixel 8"); + + var result = service.CreateSessionDefinition(target, ProfilingScenarioKind.Launch); + + result.Name.Should().Be("Pixel 8 - Launch & startup"); + result.CaptureKinds.Should().BeEquivalentTo( + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory]); + result.Duration.Should().Be(TimeSpan.FromMinutes(2)); + result.Tags.Should().NotBeNull().And.BeEmpty(); + } + + [Fact] + public async Task ValidateSessionDefinition_ReturnsErrorsForUnsupportedValues() + { + var service = new ProfilingCatalogService([]); + var capabilities = await service.GetCapabilitiesAsync(ProfilingTargetPlatform.Windows); + var definition = new ProfilingSessionDefinition( + "session-1", + "", + new ProfilingTarget( + ProfilingTargetPlatform.Android, + ProfilingTargetKind.Emulator, + "", + "Android emulator"), + ProfilingScenarioKind.Launch, + [(ProfilingCaptureKind)999], + Duration: TimeSpan.Zero, + CreatedAt: DateTimeOffset.UtcNow); + + var result = service.ValidateSessionDefinition(definition, capabilities); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(x => x.Contains("session name", StringComparison.OrdinalIgnoreCase)); + result.Errors.Should().Contain(x => x.Contains("identifier", StringComparison.OrdinalIgnoreCase)); + result.Errors.Should().Contain(x => x.Contains("does not match", StringComparison.OrdinalIgnoreCase)); + result.Errors.Should().Contain(x => x.Contains("not supported", StringComparison.OrdinalIgnoreCase)); + result.UnsupportedCaptureKinds.Should().Contain((ProfilingCaptureKind)999); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingContextServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingContextServiceTests.cs new file mode 100644 index 00000000..242f549d --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingContextServiceTests.cs @@ -0,0 +1,125 @@ +using FluentAssertions; +using MauiSherpa.Core.Models.DevFlow; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingContextServiceTests +{ + [Fact] + public void BuildNetworkSummary_UsesMostRecentRequestsAndCalculatesMetrics() + { + var now = DateTimeOffset.UtcNow; + var requests = new[] + { + new DevFlowNetworkRequest + { + Timestamp = now.AddMinutes(-10), + Method = "GET", + Url = "https://example.com/old", + DurationMs = 15, + StatusCode = 200, + RequestSize = 1, + ResponseSize = 10 + }, + new DevFlowNetworkRequest + { + Timestamp = now.AddMinutes(-1), + Method = "GET", + Url = "https://example.com/fast", + DurationMs = 120, + StatusCode = 200, + RequestSize = 20, + ResponseSize = 100 + }, + new DevFlowNetworkRequest + { + Timestamp = now.AddMinutes(-2), + Method = "POST", + Url = "https://example.com/slow", + DurationMs = 450, + StatusCode = 500, + RequestSize = 30, + ResponseSize = 0 + }, + new DevFlowNetworkRequest + { + Timestamp = now.AddMinutes(-3), + Method = "GET", + Url = "https://example.com/error", + DurationMs = 240, + Error = "timeout", + RequestSize = 40, + ResponseSize = 5 + } + }; + + var summary = ProfilingContextService.BuildNetworkSummary(requests, 3); + + summary.SampleSize.Should().Be(3); + summary.SuccessCount.Should().Be(1); + summary.FailureCount.Should().Be(2); + summary.AverageDurationMs.Should().BeApproximately(270, 0.001); + summary.P95DurationMs.Should().BeGreaterThan(400); + summary.MaxDurationMs.Should().Be(450); + summary.TotalRequestBytes.Should().Be(90); + summary.TotalResponseBytes.Should().Be(105); + summary.SlowestRequests.Select(r => r.Url).Should().ContainInOrder( + "https://example.com/slow", + "https://example.com/error", + "https://example.com/fast"); + } + + [Fact] + public void BuildVisualTreeSummary_FlattensTreeAndCalculatesCounts() + { + var roots = new[] + { + new DevFlowElementInfo + { + Id = "root", + Type = "Grid", + IsVisible = true, + Children = new List + { + new() + { + Id = "button", + Type = "Button", + IsVisible = true, + Gestures = new List { "Tap" } + }, + new() + { + Id = "stack", + Type = "VerticalStackLayout", + IsVisible = true, + Children = new List + { + new() + { + Id = "entry", + Type = "Entry", + IsVisible = false, + IsFocused = true, + Gestures = new List { "Focus" } + } + } + } + } + } + }; + + var summary = ProfilingContextService.BuildVisualTreeSummary(roots); + + summary.RootCount.Should().Be(1); + summary.TotalElementCount.Should().Be(4); + summary.VisibleElementCount.Should().Be(3); + summary.FocusedElementCount.Should().Be(1); + summary.InteractiveElementCount.Should().Be(2); + summary.MaxDepth.Should().Be(3); + summary.TopElementTypes.Should().ContainEquivalentOf(new { Type = "Grid", Count = 1 }); + summary.TopElementTypes.Should().ContainEquivalentOf(new { Type = "Button", Count = 1 }); + summary.TopElementTypes.Should().ContainEquivalentOf(new { Type = "Entry", Count = 1 }); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingPrerequisitesServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingPrerequisitesServiceTests.cs new file mode 100644 index 00000000..c5f475c6 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingPrerequisitesServiceTests.cs @@ -0,0 +1,188 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingPrerequisitesServiceTests +{ + private readonly Mock _doctorService = new(); + private readonly Mock _platformService = new(); + private readonly Mock _loggingService = new(); + + public ProfilingPrerequisitesServiceTests() + { + _platformService.SetupGet(x => x.PlatformName).Returns("macOS"); + _platformService.SetupGet(x => x.IsMacOS).Returns(true); + _platformService.SetupGet(x => x.IsMacCatalyst).Returns(false); + _platformService.SetupGet(x => x.IsWindows).Returns(false); + _platformService.SetupGet(x => x.IsLinux).Returns(false); + } + + [Fact] + public async Task GetPrerequisitesAsync_ForAndroidMemoryCapture_ReturnsReadyWhenRequiredToolsExist() + { + var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); + SetupDoctor(context, CreateDoctorReport( + context, + new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false), + new DependencyStatus("Android SDK", DependencyCategory.AndroidSdk, null, null, "/Users/test/Library/Android/sdk", DependencyStatusType.Ok, "Found SDK", false), + new DependencyStatus("Platform Tools", DependencyCategory.AndroidSdk, null, null, "Installed", DependencyStatusType.Ok, "adb available", false))); + + var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ + Package Id Version Commands + ---------------------------------------------------------- + dotnet-trace 10.0.41001 dotnet-trace + dotnet-gcdump 10.0.41001 dotnet-gcdump + dotnet-dsrouter 10.0.41001 dotnet-dsrouter + """))); + + var report = await service.GetPrerequisitesAsync( + ProfilingTargetPlatform.Android, + [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory]); + + report.IsReady.Should().BeTrue(); + report.Checks.Should().ContainSingle(x => x.Name == "dotnet-trace" && x.Status == DependencyStatusType.Ok); + report.Checks.Should().ContainSingle(x => x.Name == "dotnet-gcdump" && x.IsRequired && x.Status == DependencyStatusType.Ok); + report.Checks.Should().ContainSingle(x => x.Name == "dotnet-dsrouter" && x.IsRequired && x.Status == DependencyStatusType.Ok); + report.Checks.Should().ContainSingle(x => x.Name == "Platform Tools" && x.Status == DependencyStatusType.Ok); + } + + [Fact] + public async Task GetPrerequisitesAsync_UpgradesRequiredAndroidPlatformToolsWarningToError() + { + var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); + SetupDoctor(context, CreateDoctorReport( + context, + new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false), + new DependencyStatus("Android SDK", DependencyCategory.AndroidSdk, null, null, "/Users/test/Library/Android/sdk", DependencyStatusType.Ok, "Found SDK", false), + new DependencyStatus("Platform Tools", DependencyCategory.AndroidSdk, null, null, null, DependencyStatusType.Warning, "Platform tools not installed", true, "install-android-package:platform-tools"))); + + var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ + Package Id Version Commands + ---------------------------------------------------------- + dotnet-trace 10.0.41001 dotnet-trace + dotnet-dsrouter 10.0.41001 dotnet-dsrouter + """))); + + var report = await service.GetPrerequisitesAsync( + ProfilingTargetPlatform.Android, + [ProfilingCaptureKind.Cpu]); + + report.IsReady.Should().BeFalse(); + report.Checks.Should().ContainSingle(x => + x.Name == "Platform Tools" && + x.Status == DependencyStatusType.Error && + x.Message!.Contains("required for profiling readiness", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetPrerequisitesAsync_RequiresGcDumpForMemoryCapture() + { + var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); + SetupDoctor(context, CreateDoctorReport( + context, + new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false))); + + var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ + Package Id Version Commands + -------------------------------------------------------- + dotnet-trace 10.0.41001 dotnet-trace + """))); + + var report = await service.GetPrerequisitesAsync( + ProfilingTargetPlatform.MacOS, + [ProfilingCaptureKind.Memory]); + + report.IsReady.Should().BeFalse(); + report.Checks.Should().ContainSingle(x => + x.Name == "dotnet-gcdump" && + x.Status == DependencyStatusType.Error && + x.IsRequired); + } + + [Fact] + public async Task GetPrerequisitesAsync_DoesNotWarnWhenToolMajorDoesNotMatchActiveSdk() + { + var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); + SetupDoctor(context, CreateDoctorReport( + context, + new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false))); + + var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ + Package Id Version Commands + -------------------------------------------------------- + dotnet-trace 9.0.553801 dotnet-trace + """))); + + var report = await service.GetPrerequisitesAsync( + ProfilingTargetPlatform.MacOS, + [ProfilingCaptureKind.Cpu]); + + report.IsReady.Should().BeTrue(); + report.Checks.Should().ContainSingle(x => + x.Name == "dotnet-trace" && + x.Status == DependencyStatusType.Ok && + x.RecommendedVersion == null && + x.RequiredVersion == null && + x.SuggestedCommand == null); + } + + private ProfilingPrerequisitesService CreateService( + Func> processExecutor) + { + return new ProfilingPrerequisitesService( + _doctorService.Object, + _platformService.Object, + _loggingService.Object, + processExecutor, + NullLoggerFactory.Instance); + } + + private void SetupDoctor(DoctorContext context, DoctorReport report) + { + _doctorService.Setup(x => x.GetContextAsync(It.IsAny())) + .ReturnsAsync(context); + _doctorService.Setup(x => x.RunDoctorAsync(It.IsAny(), It.IsAny?>())) + .ReturnsAsync(report); + _doctorService.Setup(x => x.GetDotNetExecutablePath()) + .Returns("/usr/local/share/dotnet/dotnet"); + } + + private static ProcessResult CreateToolListResult(ProcessRequest request, string output) + { + if (request.Arguments.Length >= 2 && + request.Arguments[0] == "tool" && + request.Arguments[1] == "list") + { + return new ProcessResult(0, output, string.Empty, TimeSpan.Zero, ProcessState.Completed); + } + + return new ProcessResult(1, string.Empty, $"Unexpected command: {request.CommandLine}", TimeSpan.Zero, ProcessState.Failed); + } + + private static DoctorContext CreateDoctorContext(string activeSdkVersion) => new( + WorkingDirectory: "/Users/test/code/MAUI.Sherpa", + DotNetSdkPath: "/usr/local/share/dotnet", + GlobalJsonPath: "/Users/test/code/MAUI.Sherpa/global.json", + PinnedSdkVersion: "10.0.100", + PinnedWorkloadSetVersion: null, + EffectiveFeatureBand: "10.0.100", + IsPreviewSdk: false, + ActiveSdkVersion: activeSdkVersion, + RollForwardPolicy: "latestPatch", + ResolvedSdkVersion: activeSdkVersion); + + private static DoctorReport CreateDoctorReport(DoctorContext context, params DependencyStatus[] dependencies) => new( + context, + [new SdkVersionInfo(context.ActiveSdkVersion ?? "10.0.103", "10.0.100", 10, 0, false)], + AvailableSdkVersions: null, + InstalledWorkloadSetVersion: null, + AvailableWorkloadSetVersions: null, + Manifests: [], + Dependencies: dependencies, + DateTime.UtcNow); +} From c74dda0b2f13db5a4283d6a62c4f250186d08559 Mon Sep 17 00:00:00 2001 From: redth Date: Mon, 9 Mar 2026 19:30:44 -0400 Subject: [PATCH 02/67] Change profiling artifact output dirs from GUIDs to project-name/date-run paths Replace opaque GUID-based output directories (artifacts/profiling/{guid}/) with human-readable paths (artifacts/profiling/{ProjectName}/{date}-{run}/). - Add BuildDefaultOutputDirectory method that derives the project name from the project path and auto-increments run numbers per date - Simplify artifact filenames: trace.nettrace, memory.gcdump, logs.txt - Update Profiling.razor placeholder text to reflect the new pattern - Add tests for output directory naming, artifact filenames, and fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProfilingCaptureOrchestrationService.cs | 42 +++++++++- src/MauiSherpa/Pages/Profiling.razor | 22 +----- ...ofilingCaptureOrchestrationServiceTests.cs | 77 +++++++++++++++++++ 3 files changed, 116 insertions(+), 25 deletions(-) diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index a361d7f6..599ee34f 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -85,9 +85,9 @@ public async Task PlanCaptureAsync( } var diagnostics = BuildDiagnosticsConfiguration(definition.Target, normalizedOptions, _platformService.IsWindows); - var traceArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, $"{definition.Id}-trace.speedscope.json"); - var gcdumpArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, $"{definition.Id}-memory.gcdump"); - var logsArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, $"{definition.Id}-logs.txt"); + var traceArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, "trace.nettrace"); + var gcdumpArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, "memory.gcdump"); + var logsArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, "logs.txt"); var androidSdkPath = definition.Target.Platform == ProfilingTargetPlatform.Android ? await TryGetAndroidSdkPathAsync() @@ -228,7 +228,7 @@ private static ProfilingCapturePlanOptions NormalizeOptions( var workingDirectory = string.IsNullOrWhiteSpace(normalized.WorkingDirectory) ? null : normalized.WorkingDirectory.Trim(); var effectiveWorkingDirectory = workingDirectory ?? (string.IsNullOrWhiteSpace(projectPath) ? null : Path.GetDirectoryName(projectPath)); var outputDirectory = string.IsNullOrWhiteSpace(normalized.OutputDirectory) - ? Path.Combine("artifacts", "profiling", definition.Id) + ? BuildDefaultOutputDirectory(normalized.ProjectPath, definition.CreatedAt) : normalized.OutputDirectory.Trim(); var additionalBuildProperties = normalized.AdditionalBuildProperties is null ? null @@ -244,6 +244,40 @@ private static ProfilingCapturePlanOptions NormalizeOptions( }; } + private static string BuildDefaultOutputDirectory(string? projectPath, DateTimeOffset createdAt) + { + var projectName = "session"; + if (!string.IsNullOrWhiteSpace(projectPath)) + { + projectName = Path.GetFileNameWithoutExtension(projectPath); + } + + var dateStr = createdAt == default + ? DateTime.Now.ToString("yyyy-MM-dd") + : createdAt.LocalDateTime.ToString("yyyy-MM-dd"); + var baseDir = Path.Combine("artifacts", "profiling", projectName); + + var runNumber = 1; + if (Directory.Exists(baseDir)) + { + var prefix = $"{dateStr}-"; + var existingRuns = Directory.GetDirectories(baseDir) + .Select(d => Path.GetFileName(d)) + .Where(name => name!.StartsWith(prefix, StringComparison.Ordinal)) + .Select(name => { + var suffix = name!.Substring(prefix.Length); + return int.TryParse(suffix, out var n) ? n : 0; + }) + .Where(n => n > 0) + .ToList(); + + if (existingRuns.Count > 0) + runNumber = existingRuns.Max() + 1; + } + + return Path.Combine(baseDir, $"{dateStr}-{runNumber}"); + } + private static string ResolveTargetFramework(ProfilingTargetPlatform platform, string? targetFrameworkOverride) => string.IsNullOrWhiteSpace(targetFrameworkOverride) ? platform switch diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index bda256af..a410af12 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -135,7 +135,7 @@ else
- +
@if (launchMode == ProfilingCaptureLaunchMode.Attach) @@ -445,11 +445,6 @@ else - } @@ -944,27 +939,12 @@ else _ => "neutral" }; - private bool CanRunStep(ProfilingCommandStep step) - => ResolveCommandStep(step) is not null; - private async Task CopyCommandAsync(ProfilingCommandStep step) { await DialogService.CopyToClipboardAsync(BuildCommandLine(step)); await AlertService.ShowToastAsync("Copied command to clipboard."); } - private async Task RunStepAsync(ProfilingCommandStep step) - { - var resolved = ResolveCommandStep(step); - if (resolved is null) - { - await AlertService.ShowToastAsync("Provide any required runtime bindings before running this command."); - return; - } - - await ProcessModal.ShowProcessAsync(resolved, requireConfirmation: true); - } - private string BuildCommandLine(ProfilingCommandStep step) { var resolved = ResolveCommandStep(step); diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs index 9442ef93..29c7ca24 100644 --- a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs @@ -163,6 +163,83 @@ public async Task PlanCaptureAsync_MacCatalystLaunch_AddsRuntimeBindingForProces "capture-trace"); } + [Fact] + public async Task PlanCaptureAsync_DefaultOutputDirectory_UsesProjectNameAndDate() + { + var snapshot = ConnectedDevicesSnapshot.Empty with + { + AndroidEmulators = [new DeviceInfo("emulator-5554", "device", "Pixel 8", true)] + }; + _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); + + var service = CreateService(); + var createdAt = new DateTimeOffset(2026, 3, 9, 14, 0, 0, TimeSpan.Zero); + var session = _catalogService.CreateSessionDefinition( + new ProfilingTarget( + ProfilingTargetPlatform.Android, + ProfilingTargetKind.Emulator, + "emulator-5554", + "Pixel 8"), + ProfilingScenarioKind.Launch, + appId: "com.example.app"); + session = session with { CreatedAt = createdAt }; + + var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( + ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); + + plan.Validation.IsValid.Should().BeTrue(); + var dateStr = createdAt.LocalDateTime.ToString("yyyy-MM-dd"); + var expectedDir = Path.Combine("artifacts", "profiling", "HelloMaui", $"{dateStr}-1"); + plan.Options.OutputDirectory.Should().Be(expectedDir); + } + + [Fact] + public async Task PlanCaptureAsync_ArtifactFileNames_UseSimpleNames() + { + var snapshot = ConnectedDevicesSnapshot.Empty with + { + AndroidEmulators = [new DeviceInfo("emulator-5554", "device", "Pixel 8", true)] + }; + _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); + + var service = CreateService(); + var session = _catalogService.CreateSessionDefinition( + new ProfilingTarget( + ProfilingTargetPlatform.Android, + ProfilingTargetKind.Emulator, + "emulator-5554", + "Pixel 8"), + ProfilingScenarioKind.Launch, + appId: "com.example.app"); + + var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( + ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); + + plan.Validation.IsValid.Should().BeTrue(); + plan.ExpectedArtifacts.Should().Contain(a => a.FileName == "trace.nettrace"); + plan.ExpectedArtifacts.Should().Contain(a => a.FileName == "memory.gcdump"); + } + + [Fact] + public async Task PlanCaptureAsync_NoProjectPath_FallsBackToSessionInOutputDir() + { + var service = CreateService(); + var session = _catalogService.CreateSessionDefinition( + new ProfilingTarget( + ProfilingTargetPlatform.MacCatalyst, + ProfilingTargetKind.Desktop, + "local-desktop", + "MAUI Sherpa"), + ProfilingScenarioKind.Interaction, + appId: "codes.redth.mauisherpa"); + + var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions()); + + // Missing project path in launch mode causes validation error, but for attach scenarios + // the output dir still uses "session" fallback when no project path is given + plan.Options.OutputDirectory.Should().Contain(Path.Combine("artifacts", "profiling", "session")); + } + [Fact] public async Task PlanCaptureAsync_LaunchWithoutProjectPath_ReturnsValidationError() { From 32f32edd2634bcff25718e239fe214b0471b676e Mon Sep 17 00:00:00 2001 From: redth Date: Mon, 9 Mar 2026 19:37:12 -0400 Subject: [PATCH 03/67] feat: pipeline runner service, capture & results step UI - Add IProfilingSessionRunner interface with RunAsync, StopCapture, Cancel - Add ProfilingSessionRunnerService with dependency-ordered execution, parallel step support, long-running process management, SIGINT stop - Add ProfilingPipelineModels (state enums, step status, pipeline result) - Remove per-command 'Run in Sherpa' buttons from step 3 (Review & Plan) - Implement step 4 (Capture): live pipeline status, per-step cards with expandable output logs, Stop Capture and Cancel buttons - Implement step 5 (Results): artifact listing with file sizes, Reveal in Finder, Open in speedscope, Copy path, session summary, New Session - Register IProcessExecutionService as Transient (per-step instances) - Register IProfilingSessionRunner as Transient Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.Core/Interfaces.cs | 37 + .../Profiling/ProfilingPipelineModels.cs | 75 ++ src/MauiSherpa/MauiProgram.cs | 3 +- src/MauiSherpa/Pages/Profiling.razor | 702 +++++++++++++++++- .../Services/ProfilingSessionRunnerService.cs | 396 ++++++++++ 5 files changed, 1198 insertions(+), 15 deletions(-) create mode 100644 src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs create mode 100644 src/MauiSherpa/Services/ProfilingSessionRunnerService.cs diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 85905d67..e2d6aa29 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -285,7 +285,44 @@ Task> AnalyzeArtifactsAsync( CancellationToken ct = default); } +/// +/// Executes a profiling capture plan as a coordinated multi-process pipeline. +/// Handles dependency ordering, parallel execution, long-running processes, +/// graceful stop, and artifact collection. +/// +public interface IProfilingSessionRunner : IDisposable +{ + /// Current pipeline state + ProfilingPipelineState State { get; } + + /// Status of each step in the pipeline + IReadOnlyList Steps { get; } + + /// Fired when pipeline state changes + event EventHandler? PipelineStateChanged; + /// Fired when a step's state changes + event EventHandler? StepStateChanged; + + /// Fired when a step produces output + event EventHandler? StepOutputReceived; + + /// + /// Execute the full pipeline. Returns when all steps complete (or fail/cancel). + /// + Task RunAsync(ProfilingCapturePlan plan, CancellationToken ct = default); + + /// + /// Gracefully stop capture — sends SIGINT to ManualStop steps, waits for exit. + /// After those exit, collects artifacts. + /// + void StopCapture(); + + /// + /// Abort everything immediately — kills all running processes. + /// + void Cancel(); +} public interface IAndroidSdkSettingsService { diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs new file mode 100644 index 00000000..5b95bdca --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +namespace MauiSherpa.Core.Models.Profiling; + +public enum ProfilingPipelineState +{ + NotStarted, + Running, + WaitingForStop, + Completing, + Completed, + Failed, + Cancelled +} + +public enum ProfilingStepState +{ + Pending, + WaitingForDependencies, + Running, + Completed, + Failed, + Stopped, + Skipped, + Cancelled +} + +public class ProfilingStepStatus +{ + public required string StepId { get; init; } + public required string DisplayName { get; init; } + public required ProfilingCommandStepKind Kind { get; init; } + public ProfilingStepState State { get; set; } = ProfilingStepState.Pending; + public List OutputLines { get; } = new(); + public TimeSpan? Duration { get; set; } + public int? ExitCode { get; set; } + public int? ProcessId { get; set; } + public string? ErrorMessage { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public bool IsLongRunning { get; init; } + public bool CanRunParallel { get; init; } + public ProfilingStopTrigger StopTrigger { get; init; } +} + +public record ProfilingStepOutputLine(string Text, bool IsError, DateTime Timestamp); + +public record ProfilingPipelineResult( + bool Success, + TimeSpan TotalDuration, + ProfilingPipelineState FinalState, + IReadOnlyList StepResults, + IReadOnlyList ArtifactPaths, + IReadOnlyList MissingArtifacts); + +public class ProfilingPipelineStateChangedEventArgs : EventArgs +{ + public ProfilingPipelineState OldState { get; init; } + public ProfilingPipelineState NewState { get; init; } +} + +public class ProfilingStepStateChangedEventArgs : EventArgs +{ + public required string StepId { get; init; } + public ProfilingStepState OldState { get; init; } + public ProfilingStepState NewState { get; init; } +} + +public class ProfilingStepOutputEventArgs : EventArgs +{ + public required string StepId { get; init; } + public required string Text { get; init; } + public bool IsError { get; init; } +} diff --git a/src/MauiSherpa/MauiProgram.cs b/src/MauiSherpa/MauiProgram.cs index b8b61a3f..3c4eac43 100644 --- a/src/MauiSherpa/MauiProgram.cs +++ b/src/MauiSherpa/MauiProgram.cs @@ -92,7 +92,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); // Process execution services - builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); @@ -117,6 +117,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index a410af12..19a3141a 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -13,6 +13,7 @@ @inject IToolbarService ToolbarService @inject IPlatformService Platform @inject IProcessModalService ProcessModal +@inject IProfilingSessionRunner PipelineRunner @implements IDisposable @@ -471,15 +472,131 @@ else
Capture +
+ @if (pipelineState == ProfilingPipelineState.Running) + { + @FormatElapsed(pipelineElapsed) + } +
-
- -
Capture session running...
-
This step will be implemented in a future update.
-
+ @if (pipelineState == ProfilingPipelineState.NotStarted) + { +
+ +
Starting capture pipeline...
+
+ } + else + { +
+ + @GetPipelineStateText() +
+ +
+ @foreach (var stepStatus in PipelineRunner.Steps) + { +
+
+
+ @switch (stepStatus.State) + { + case ProfilingStepState.Running: + + break; + case ProfilingStepState.Completed: + + break; + case ProfilingStepState.Failed: + + break; + case ProfilingStepState.Stopped: + + break; + case ProfilingStepState.Skipped: + + break; + case ProfilingStepState.Cancelled: + + break; + default: + + break; + } +
+
+
@stepStatus.DisplayName
+
+ @stepStatus.Kind + @if (stepStatus.IsLongRunning) + { + Long-running + } + @if (stepStatus.Duration.HasValue) + { + @FormatElapsed(stepStatus.Duration.Value) + } + else if (stepStatus.State == ProfilingStepState.Running && stepStatus.StartedAt.HasValue) + { + @FormatElapsed(DateTime.Now - stepStatus.StartedAt.Value) + } +
+
+
+ +
+
+ @if (stepStatus.ErrorMessage is not null && stepStatus.State == ProfilingStepState.Failed) + { +
+ @stepStatus.ErrorMessage +
+ } + @if (expandedStepLogs.Contains(stepStatus.StepId)) + { +
+ @if (stepStatus.OutputLines.Count == 0) + { +
No output yet
+ } + else + { + @foreach (var line in stepStatus.OutputLines.TakeLast(200)) + { +
@line.Text
+ } + } +
+ } +
+ } +
+ }
+ + @if (pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) + { +
+ @if (pipelineState == ProfilingPipelineState.WaitingForStop) + { +
+ + Waiting for capture processes to finish... +
+ } + else + { + + } + +
+ } } @if (currentStep == 5) @@ -489,24 +606,113 @@ else Results
-
- -
Results will appear here
-
This step will be implemented in a future update.
-
+ @if (pipelineResult is null) + { +
+ +
No results yet
+
+ } + else + { +
+
+ +
+
+
+ @(pipelineResult.Success ? "Capture completed successfully" : "Capture completed with errors") +
+
+ @FormatElapsed(pipelineResult.TotalDuration) + @pipelineResult.ArtifactPaths.Count artifact(s) captured + @if (pipelineResult.MissingArtifacts.Count > 0) + { + @pipelineResult.MissingArtifacts.Count expected artifact(s) missing + } +
+
+
+ + @if (pipelineResult.ArtifactPaths.Count > 0) + { +
Captured artifacts
+
+ @foreach (var artifactPath in pipelineResult.ArtifactPaths) + { + var fileName = Path.GetFileName(artifactPath); + var extension = Path.GetExtension(artifactPath).ToLowerInvariant(); + var fileSize = GetFileSize(artifactPath); +
+
+ +
+
+
@fileName
+
@artifactPath
+ @if (fileSize is not null) + { +
@fileSize
+ } +
+
+ @if (extension is ".nettrace" or ".speedscope.json" or ".json") + { + + } + + +
+
+ } +
+ } + + @if (pipelineResult.MissingArtifacts.Count > 0) + { +
Missing artifacts
+
+ @foreach (var missing in pipelineResult.MissingArtifacts) + { +
+
+ +
+
+
@Path.GetFileName(missing)
+
@missing
+
Expected but not found
+
+
+ } +
+ } + }
+ +
+ +
}
- @if (currentStep > 0) + @if (currentStep > 0 && currentStep != 4) { - }
- @if (currentStep < TotalSteps - 1) + @if (currentStep < 4) { +
+ } + else + { + @if (filteredSessions.Count == 0 && !string.IsNullOrEmpty(sessionSearchText)) + { +
+ +

No matching sessions

+

Try a different search term.

+
+ } + else + { +
+ @foreach (var session in filteredSessions) + { + var isExpanded = expandedSessionId == session.Id; +
+
+
+ +
+
+
@session.Name
+
+ @session.CreatedAt.LocalDateTime.ToString("MMM d, yyyy h:mm tt") + @session.Target.DisplayName + @if (session.Pipeline is not null) + { + @FormatElapsed(session.Pipeline.Duration) + } + @session.Artifacts.Count artifact(s) +
+
+
+ @foreach (var kind in session.CaptureKinds) + { + @kind + } + + @session.Status + +
+
+ +
+
+ + @if (isExpanded) + { +
+ @if (session.Project is not null) + { +
+ Project + @session.Project.Name +
+ } +
+ Platform + @session.Target.Platform — @session.Target.Kind +
+ @if (session.Pipeline is not null) + { +
+ Pipeline + + @(session.Pipeline.Success ? "✓ Succeeded" : "✗ Failed") — + @session.Pipeline.Steps.Count step(s) + +
+ } + + @if (session.Artifacts.Count > 0) + { +
Artifacts
+
+ @foreach (var artifact in session.Artifacts) + { + var filePath = session.DirectoryPath is not null + ? Path.Combine(session.DirectoryPath, artifact.FileName) + : artifact.FileName; + var extension = GetArtifactExtension(artifact.FileName); + var isTraceFile = extension is ".nettrace" or ".speedscope.json"; + var isGcDump = extension is ".gcdump"; +
+
+ +
+
+
@artifact.FileName
+ @if (artifact.SizeBytes is not null) + { +
@FormatBytes(artifact.SizeBytes.Value)
+ } +
+
+ @if (isTraceFile) + { + + } + @if (isGcDump) + { + + } + + +
+
+ } +
+ } + + @* Inline Speedscope Viewer for session list *@ + @if (showSpeedscopeViewer && speedscopeFilePath is not null) + { +
+ CPU Trace Viewer + +
+
+ @if (speedscopeLoading) + { +
+ +
Loading trace viewer...
+
+ } + +
+ } + + @* Inline GC Dump Viewer for session list *@ + @if (showGcDumpViewer && gcDumpReport is not null) + { +
+ GC Dump — Heap Statistics + +
+
+ @gcDumpReport.TotalCount.ToString("N0") objects + @FormatBytes(gcDumpReport.TotalSize) + @gcDumpReport.Types.Count types +
+
+ +
+
+ + + + + + + + + + + @foreach (var entry in GetFilteredGcDumpTypes()) + { + var pct = gcDumpReport.TotalSize > 0 + ? (double)entry.Size / gcDumpReport.TotalSize * 100 + : 0; + + + + + + + } + +
+ Type Name + @if (gcDumpSortColumn == "name") { } + + Count + @if (gcDumpSortColumn == "count") { } + + Total Size + @if (gcDumpSortColumn == "size") { } + %
+ @entry.TypeName + @entry.Count.ToString("N0")@FormatBytes(entry.Size) +
+
+ @pct.ToString("F1")% +
+
+
+ } + else if (showGcDumpViewer && gcDumpLoading) + { +
+ GC Dump Analysis +
+
+ +
Analyzing GC dump...
+
+ } + else if (showGcDumpViewer && gcDumpError is not null) + { +
+ GC Dump Analysis + +
+
+ + @gcDumpError +
+ } + +
+ + + +
+
+ } +
+ } +
+ } + } } -else +else if (viewMode == ViewMode.Wizard) { + @if (catalog is null) + { +
+ + Loading profiling catalog... +
+ } + else + {
@for (int i = 0; i < WizardSteps.Length; i++) { @@ -828,6 +1101,9 @@ else }
+ @if (currentStep > 0 && currentStep != 4) { }
+ } @* end catalog else *@ } @code { + private ViewMode viewMode = ViewMode.SessionList; + private List sessions = new(); + private List filteredSessions = new(); + private bool isLoadingSessions; + private string sessionSearchText = ""; + private string? expandedSessionId; + private string? activeSessionId; // the session being captured + private ProfilingCatalog? catalog; private ConnectedDevicesSnapshot snapshot = ConnectedDevicesSnapshot.Empty; private List androidEmulators = new(); @@ -934,12 +1219,15 @@ else protected override async Task OnInitializedAsync() { - ToolbarService.SetItems( - new ToolbarAction("refresh", "Refresh", "arrow.clockwise") - ); + SetupToolbar(); ToolbarService.ToolbarItemClicked += OnToolbarItemClicked; + ToolbarService.SearchTextChanged += OnSearchTextChanged; DeviceMonitor.Changed += OnDeviceMonitorChanged; + // Load sessions first (fast) + await LoadSessionsAsync(); + + // Load catalog in background (needed for wizard) catalog = await ProfilingCatalogService.GetCatalogAsync(); selectedPlatform = catalog.Platforms.FirstOrDefault(platform => platform.Platform == ProfilingTargetPlatform.Android)?.Platform ?? catalog.Platforms.First().Platform; @@ -952,8 +1240,24 @@ else private void OnToolbarItemClicked(string actionId) { - if (actionId == "refresh") - _ = RefreshTargetsAsync(forceRefresh: true); + if (actionId == "create") + _ = InvokeAsync(StartNewWizard); + else if (actionId == "import") + _ = ImportSessionFromZipAsync(); + else if (actionId == "refresh") + { + if (viewMode == ViewMode.SessionList) + _ = LoadSessionsAsync(); + else + _ = RefreshTargetsAsync(forceRefresh: true); + } + } + + private void OnSearchTextChanged(string text) + { + sessionSearchText = text; + ApplySessionFilter(); + InvokeAsync(StateHasChanged); } private void OnDeviceMonitorChanged(ConnectedDevicesSnapshot updatedSnapshot) @@ -1392,6 +1696,16 @@ else { if (capturePlan is null) return; + // Generate session ID and set output directory to managed storage + var projectName = projectPath is not null + ? Path.GetFileNameWithoutExtension(projectPath) + : null; + activeSessionId = SessionStorage.GenerateSessionId(projectName); + var sessionDir = SessionStorage.GetSessionDirectoryPath(activeSessionId); + + // Update the capture plan to use the session directory as output + capturePlan = capturePlan with { OutputDirectory = sessionDir }; + pipelineState = ProfilingPipelineState.NotStarted; pipelineResult = null; expandedStepLogs.Clear(); @@ -1415,6 +1729,9 @@ else pipelineResult = await PipelineRunner.RunAsync(capturePlan); pipelineState = pipelineResult.FinalState; + // Save session manifest + await SaveCurrentSessionAsync(); + // Auto-advance to results on completion if (pipelineResult.Success || pipelineState == ProfilingPipelineState.Completed) { @@ -1531,6 +1848,11 @@ else // Results helpers private void StartNewSession() + { + StartNewWizard(); + } + + private void StartNewWizard() { currentStep = 0; capturePlan = null; @@ -1538,10 +1860,300 @@ else pipelineResult = null; pipelineState = ProfilingPipelineState.NotStarted; expandedStepLogs.Clear(); + activeSessionId = null; CloseSpeedscopeViewer(); CloseGcDumpViewer(); + viewMode = ViewMode.Wizard; + SetupToolbar(); + } + + private void BackToSessionList() + { + if (pipelineState == ProfilingPipelineState.Running) + return; // Don't navigate away during active capture + + viewMode = ViewMode.SessionList; + CloseSpeedscopeViewer(); + CloseGcDumpViewer(); + SetupToolbar(); + _ = LoadSessionsAsync(); + } + + private void SetupToolbar() + { + if (viewMode == ViewMode.SessionList) + { + ToolbarService.ClearItems(); + ToolbarService.SetItems( + new ToolbarAction("create", "New Session", "plus"), + new ToolbarAction("import", "Import", "square.and.arrow.down"), + new ToolbarAction("refresh", "Refresh", "arrow.clockwise") + ); + ToolbarService.SetSearch("Search sessions..."); + } + else + { + ToolbarService.ClearItems(); + ToolbarService.SetItems( + new ToolbarAction("refresh", "Refresh", "arrow.clockwise") + ); + } + } + + private async Task LoadSessionsAsync() + { + isLoadingSessions = true; + StateHasChanged(); + + try + { + sessions = (await SessionStorage.GetSessionsAsync()).ToList(); + ApplySessionFilter(); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Failed to load sessions: {ex.Message}"); + } + finally + { + isLoadingSessions = false; + StateHasChanged(); + } + } + + private void ApplySessionFilter() + { + if (string.IsNullOrWhiteSpace(sessionSearchText)) + { + filteredSessions = sessions; + } + else + { + filteredSessions = sessions + .Where(s => + s.Name.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || + s.Target.DisplayName.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || + s.Target.Platform.ToString().Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || + (s.Project?.Name?.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + } + } + + private void ToggleSessionExpand(string sessionId) + { + if (expandedSessionId == sessionId) + { + expandedSessionId = null; + CloseSpeedscopeViewer(); + CloseGcDumpViewer(); + } + else + { + expandedSessionId = sessionId; + CloseSpeedscopeViewer(); + CloseGcDumpViewer(); + } + } + + private async Task DeleteSessionAsync(ProfilingSessionManifest session) + { + var confirmed = await AlertService.ShowConfirmAsync( + "Delete Session", + $"Delete \"{session.Name}\" and all its artifacts? This cannot be undone."); + if (!confirmed) return; + + try + { + await SessionStorage.DeleteSessionAsync(session.Id); + sessions.Remove(session); + ApplySessionFilter(); + if (expandedSessionId == session.Id) + expandedSessionId = null; + await AlertService.ShowToastAsync("Session deleted"); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Failed to delete session: {ex.Message}"); + } + } + + private async Task ExportSessionAsync(ProfilingSessionManifest session) + { + try + { + var outputPath = await DialogService.PickSaveFileAsync( + "Export Profiling Session", + $"{session.Id}", + ".zip"); + if (string.IsNullOrEmpty(outputPath)) return; + + // PickSaveFileAsync creates an empty file; delete so ZipFile can create + if (File.Exists(outputPath)) File.Delete(outputPath); + + await SessionStorage.ExportSessionAsync(session.Id, outputPath); + await AlertService.ShowToastAsync("Session exported"); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Export failed: {ex.Message}"); + } } + private async Task ImportSessionFromZipAsync() + { + try + { + var filePath = await DialogService.PickOpenFileAsync( + "Import Profiling Session", + new[] { ".zip" }); + if (string.IsNullOrEmpty(filePath)) return; + + var imported = await SessionStorage.ImportSessionAsync(filePath); + if (imported is null) + { + await AlertService.ShowToastAsync("Invalid session archive — missing session.json"); + return; + } + + await LoadSessionsAsync(); + await AlertService.ShowToastAsync($"Session \"{imported.Name}\" imported"); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Import failed: {ex.Message}"); + } + } + + private void RevealSessionFolder(ProfilingSessionManifest session) + { + if (session.DirectoryPath is null) return; + RevealInFinder(session.DirectoryPath); + } + + private async Task SaveCurrentSessionAsync() + { + if (pipelineResult is null || capturePlan is null) return; + + try + { + var projectName = projectPath is not null + ? Path.GetFileNameWithoutExtension(projectPath) + : null; + var sessionId = activeSessionId ?? SessionStorage.GenerateSessionId(projectName); + activeSessionId = sessionId; + + var target = SelectedTarget; + var manifest = new ProfilingSessionManifest + { + Id = sessionId, + Name = $"{projectName ?? "Session"} — {selectedPlatform} {target?.DisplayName ?? "Unknown"}", + Status = pipelineResult.Success ? ProfilingSessionStatus.Completed + : (pipelineState == ProfilingPipelineState.Cancelled ? ProfilingSessionStatus.Cancelled + : ProfilingSessionStatus.Failed), + CreatedAt = pipelineStartTime, + CompletedAt = DateTimeOffset.UtcNow, + Target = new ProfilingSessionTarget + { + Platform = selectedPlatform, + Kind = target?.Kind ?? ProfilingTargetKind.Desktop, + Identifier = target?.Identifier ?? "", + DisplayName = target?.DisplayName ?? selectedPlatform.ToString() + }, + Project = projectPath is not null ? new ProfilingSessionProject + { + Path = projectPath, + Name = projectName!, + Configuration = configuration, + TargetFramework = targetFrameworkOverride + } : null, + CaptureKinds = selectedCaptureKinds.ToList(), + Options = new ProfilingSessionOptions + { + LaunchMode = launchMode, + DiagnosticPort = diagnosticPort, + SuspendAtStartup = suspendAtStartup, + ProcessId = int.TryParse(processIdText, out var pid) ? pid : null, + Scenario = selectedScenario + }, + Pipeline = new ProfilingSessionPipelineSummary + { + Success = pipelineResult.Success, + Duration = pipelineResult.TotalDuration, + Steps = pipelineResult.StepResults.Select(step => new ProfilingSessionStepSummary + { + Id = step.StepId, + DisplayName = step.DisplayName, + State = step.State.ToString(), + CommandLine = capturePlan.Commands.FirstOrDefault(c => c.Id == step.StepId)?.CommandLine + }).ToList() + }, + Artifacts = new List() + }; + + // Add discovered artifacts + var sessionDir = SessionStorage.GetSessionDirectoryPath(sessionId); + foreach (var artifactPath in pipelineResult.ArtifactPaths) + { + var fileName = Path.GetFileName(artifactPath); + var extension = GetArtifactExtension(fileName); + var kind = extension switch + { + ".nettrace" => ProfilingArtifactKind.Trace, + ".speedscope.json" => ProfilingArtifactKind.Export, + ".gcdump" => ProfilingArtifactKind.GcDump, + ".log" => ProfilingArtifactKind.Log, + _ => ProfilingArtifactKind.Other + }; + + long? size = null; + try { if (File.Exists(artifactPath)) size = new FileInfo(artifactPath).Length; } catch { } + + manifest.Artifacts.Add(new ProfilingSessionArtifact + { + FileName = fileName, + Kind = kind, + SizeBytes = size + }); + } + + // Add log files from the session directory + if (Directory.Exists(sessionDir)) + { + foreach (var logFile in Directory.GetFiles(sessionDir, "*.log")) + { + var logName = Path.GetFileName(logFile); + if (manifest.Artifacts.Any(a => a.FileName == logName)) continue; + long? size = null; + try { size = new FileInfo(logFile).Length; } catch { } + manifest.Artifacts.Add(new ProfilingSessionArtifact + { + FileName = logName, + Kind = ProfilingArtifactKind.Log, + SizeBytes = size + }); + } + } + + await SessionStorage.SaveSessionAsync(manifest); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Failed to save session: {ex.Message}"); + } + } + + private static string GetPlatformIcon(ProfilingTargetPlatform platform) => platform switch + { + ProfilingTargetPlatform.Android => "fa-brands fa-android", + ProfilingTargetPlatform.iOS => "fa-brands fa-apple", + ProfilingTargetPlatform.MacCatalyst => "fa-laptop", + ProfilingTargetPlatform.MacOS => "fa-laptop", + ProfilingTargetPlatform.Windows => "fa-brands fa-windows", + _ => "fa-desktop" + }; + + private enum ViewMode { SessionList, Wizard } + private static string? GetFileSize(string path) { try @@ -1743,6 +2355,7 @@ else public void Dispose() { ToolbarService.ToolbarItemClicked -= OnToolbarItemClicked; + ToolbarService.SearchTextChanged -= OnSearchTextChanged; ToolbarService.ClearItems(); DeviceMonitor.Changed -= OnDeviceMonitorChanged; pipelineTimer?.Dispose(); @@ -2780,4 +3393,169 @@ else .gcdump-error { color: var(--text-warning, #eab308); } + + /* Session List */ + .empty-state { + text-align: center; + padding: 60px 40px; + color: var(--text-muted); + } + + .empty-state i { + margin-bottom: 16px; + opacity: 0.4; + } + + .empty-state h3 { + color: var(--text-primary); + margin-bottom: 8px; + } + + .empty-state p { + max-width: 28rem; + margin: 0 auto 1.2rem; + line-height: 1.5; + } + + .session-list { + display: grid; + gap: 0.6rem; + } + + .session-card { + background: var(--card-bg, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 10px; + overflow: hidden; + transition: box-shadow 0.15s; + } + + .session-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + } + + .session-card.expanded { + border-color: var(--accent-primary, #0078d4); + } + + .session-card-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + cursor: pointer; + user-select: none; + } + + .session-card-icon { + font-size: 1.3rem; + width: 2rem; + text-align: center; + color: var(--text-secondary); + } + + .session-card-info { + flex: 1; + min-width: 0; + } + + .session-card-title { + font-weight: 600; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .session-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.25rem; + font-size: 0.75rem; + color: var(--text-secondary); + } + + .session-card-meta i { + margin-right: 0.2rem; + } + + .session-card-badges { + display: flex; + gap: 0.4rem; + flex-shrink: 0; + } + + .badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 6px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .badge-kind { + background: color-mix(in srgb, var(--accent-primary, #0078d4) 15%, transparent); + color: var(--accent-primary, #0078d4); + } + + .badge-status { + color: white; + } + + .badge-completed { + background: #16a34a; + } + + .badge-failed { + background: #dc2626; + } + + .badge-cancelled { + background: #ca8a04; + } + + .badge-inprogress { + background: #2563eb; + } + + .session-card-expand { + color: var(--text-muted); + font-size: 0.8rem; + padding: 0 0.25rem; + } + + .session-card-body { + border-top: 1px solid var(--border-color); + padding: 1rem; + } + + .session-detail-row { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.3rem 0; + font-size: 0.85rem; + } + + .detail-label { + color: var(--text-secondary); + font-weight: 600; + min-width: 5rem; + font-size: 0.8rem; + } + + .detail-value { + color: var(--text-primary); + } + + .session-card-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); + } diff --git a/src/MauiSherpa/Services/ProfilingSessionStorageService.cs b/src/MauiSherpa/Services/ProfilingSessionStorageService.cs new file mode 100644 index 00000000..723ca17b --- /dev/null +++ b/src/MauiSherpa/Services/ProfilingSessionStorageService.cs @@ -0,0 +1,242 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Services; + +/// +/// Manages persistent profiling sessions stored under AppDataPath/profiling/. +/// Each session is a folder containing session.json + artifact files. +/// +public class ProfilingSessionStorageService : IProfilingSessionStorageService +{ + private readonly string _profilingRoot; + private readonly ILoggingService _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + private const string ManifestFileName = "session.json"; + + public ProfilingSessionStorageService(ILoggingService logger) + { + _logger = logger; + _profilingRoot = Path.Combine(AppDataPath.GetAppDataDirectory(), "profiling"); + Directory.CreateDirectory(_profilingRoot); + } + + public async Task> GetSessionsAsync(CancellationToken ct = default) + { + var sessions = new List(); + + if (!Directory.Exists(_profilingRoot)) + return sessions; + + foreach (var dir in Directory.GetDirectories(_profilingRoot)) + { + ct.ThrowIfCancellationRequested(); + var manifestPath = Path.Combine(dir, ManifestFileName); + if (!File.Exists(manifestPath)) + continue; + + try + { + var manifest = await ReadManifestAsync(manifestPath, ct); + if (manifest is not null) + { + manifest.DirectoryPath = dir; + sessions.Add(manifest); + } + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to read session manifest at {manifestPath}: {ex.Message}"); + } + } + + // Most recent first + sessions.Sort((a, b) => b.CreatedAt.CompareTo(a.CreatedAt)); + return sessions; + } + + public async Task GetSessionAsync(string sessionId, CancellationToken ct = default) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + var manifestPath = Path.Combine(dir, ManifestFileName); + + if (!File.Exists(manifestPath)) + return null; + + var manifest = await ReadManifestAsync(manifestPath, ct); + if (manifest is not null) + manifest.DirectoryPath = dir; + return manifest; + } + + public async Task SaveSessionAsync(ProfilingSessionManifest manifest, CancellationToken ct = default) + { + var dir = GetSessionDirectoryPath(manifest.Id); + var manifestPath = Path.Combine(dir, ManifestFileName); + + // Update artifact sizes from disk + foreach (var artifact in manifest.Artifacts) + { + var artifactPath = Path.Combine(dir, artifact.FileName); + if (File.Exists(artifactPath)) + { + var info = new FileInfo(artifactPath); + // Use reflection-free approach: create new record with updated size + if (artifact.SizeBytes is null || artifact.SizeBytes == 0) + { + var idx = manifest.Artifacts.IndexOf(artifact); + if (idx >= 0) + { + manifest.Artifacts[idx] = artifact with { SizeBytes = info.Length }; + } + } + } + } + + manifest.DirectoryPath = dir; + + var json = JsonSerializer.Serialize(manifest, JsonOptions); + await File.WriteAllTextAsync(manifestPath, json, ct); + + _logger.LogInformation($"Session manifest saved: {manifest.Id}"); + } + + public Task DeleteSessionAsync(string sessionId, CancellationToken ct = default) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + _logger.LogInformation($"Session deleted: {sessionId}"); + } + + return Task.CompletedTask; + } + + public string GetSessionDirectoryPath(string sessionId) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + Directory.CreateDirectory(dir); + return dir; + } + + public string GenerateSessionId(string? projectName = null) + { + var datePart = DateTime.Now.ToString("yyyy-MM-dd"); + var namePart = SanitizePath(projectName ?? "session"); + var baseName = $"{datePart}_{namePart}"; + + // Find next available run number + var runNumber = 1; + while (Directory.Exists(Path.Combine(_profilingRoot, $"{baseName}_{runNumber}"))) + { + runNumber++; + } + + return $"{baseName}_{runNumber}"; + } + + public async Task ExportSessionAsync(string sessionId, string outputZipPath, CancellationToken ct = default) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + + if (!Directory.Exists(dir)) + throw new DirectoryNotFoundException($"Session directory not found: {dir}"); + + // Delete existing zip if present (save dialog may have created empty file) + if (File.Exists(outputZipPath)) + File.Delete(outputZipPath); + + await Task.Run(() => ZipFile.CreateFromDirectory(dir, outputZipPath), ct); + _logger.LogInformation($"Session exported: {sessionId} → {outputZipPath}"); + } + + public async Task ImportSessionAsync(string zipPath, CancellationToken ct = default) + { + if (!File.Exists(zipPath)) + return null; + + // Extract to a temp directory first to read manifest + var tempDir = Path.Combine(Path.GetTempPath(), $"sherpa-import-{Guid.NewGuid():N}"); + try + { + await Task.Run(() => ZipFile.ExtractToDirectory(zipPath, tempDir), ct); + + var manifestPath = Path.Combine(tempDir, ManifestFileName); + if (!File.Exists(manifestPath)) + { + _logger.LogWarning($"Imported zip has no {ManifestFileName}"); + return null; + } + + var manifest = await ReadManifestAsync(manifestPath, ct); + if (manifest is null) + return null; + + // Move to managed location (use a new ID if collision) + var targetDir = Path.Combine(_profilingRoot, SanitizePath(manifest.Id)); + if (Directory.Exists(targetDir)) + { + // Generate new ID to avoid collision + var newId = GenerateSessionId(manifest.Name); + targetDir = Path.Combine(_profilingRoot, SanitizePath(newId)); + // We don't change manifest.Id since the record is immutable — just store under new folder + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!); + + // Move the extracted folder to the managed location + if (Directory.Exists(targetDir)) + Directory.Delete(targetDir, true); + Directory.Move(tempDir, targetDir); + + manifest.DirectoryPath = targetDir; + _logger.LogInformation($"Session imported: {manifest.Id} from {zipPath}"); + return manifest; + } + catch (Exception ex) + { + _logger.LogError($"Failed to import session from {zipPath}: {ex.Message}", ex); + return null; + } + finally + { + // Clean up temp directory if it still exists + if (Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, true); } + catch { /* best effort */ } + } + } + } + + private static async Task ReadManifestAsync(string path, CancellationToken ct) + { + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct); + } + + private static string SanitizePath(string input) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new char[input.Length]; + for (int i = 0; i < input.Length; i++) + { + sanitized[i] = Array.IndexOf(invalid, input[i]) >= 0 ? '_' : input[i]; + } + return new string(sanitized).Trim('.'); + } +} From 6f9e853c405bef0ced946db404ae1a1842e14f68 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 12:18:43 -0400 Subject: [PATCH 20/67] Extract profiling wizard steps 0-4 into modal dialog Move the inline wizard (steps 0-4: Session Setup, Capture Kinds, Target, Review & Plan, Capture) from Profiling.razor into a new ProfilingCaptureWizardModal.razor component that uses the established WizardFormPage pattern for native footer buttons (Back/Next/Start Capture). - Create ProfilingCaptureWizardModal.razor at /modal/profiling-wizard route - Wire HybridFormBridge for native Back/Next/Submit button control - Handle pipeline completion: save session manifest, set bridge.Result - Hard link already exists for macOS project - Simplify Profiling.razor to session list only (remove ViewMode enum) - Add StartNewWizardAsync using IFormModalService.ShowAsync pattern - Auto-expand new session in list after wizard completes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Modals/ProfilingCaptureWizardModal.razor | 1935 ++++++++++ src/MauiSherpa/Pages/Profiling.razor | 3362 +++-------------- 2 files changed, 2391 insertions(+), 2906 deletions(-) create mode 100644 src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor new file mode 100644 index 00000000..61489768 --- /dev/null +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -0,0 +1,1935 @@ +@page "/modal/profiling-wizard" +@using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Models.Profiling +@using MauiSherpa.Core.Requests.Android +@using MauiSherpa.Core.Requests.Apple +@using MauiSherpa.Core.Requests.Profiling +@using MauiSherpa.Pages.Forms +@using Shiny.Mediator +@inject HybridFormBridgeHolder BridgeHolder +@inject IMediator Mediator +@inject IProfilingCatalogService ProfilingCatalogService +@inject IDeviceMonitorService DeviceMonitor +@inject IDialogService DialogService +@inject IAlertService AlertService +@inject IPlatformService Platform +@inject IProfilingSessionRunner PipelineRunner +@inject IProfilingSessionStorageService SessionStorage +@inject IProfilingArtifactConverterService ArtifactConverter +@inject IJSRuntime JS +@implements IDisposable + +@{ + var bridge = BridgeHolder.Current; +} + +@if (bridge != null) +{ +
+ @if (catalog is null) + { +
+ + Loading profiling catalog... +
+ } + else + { + +
+ @for (int i = 0; i < WizardStepLabels.Length; i++) + { + var stepIndex = i; + var label = WizardStepLabels[i]; +
stepIndex ? "completed" : "")"> +
@(currentStep > stepIndex ? "✓" : (stepIndex + 1).ToString())
+
@label
+
+ @if (i < WizardStepLabels.Length - 1) + { +
+ } + } +
+ +
+ @if (currentStep == 0) + { +
+
+ Session setup +
+
+
+ +
+ + +
+
+ Launch planning needs a project path. Connect-to-running-app flows can omit it if you provide a process id. +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Advanced settings +
+ @if (showAdvanced) + { +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (launchMode == ProfilingCaptureLaunchMode.Attach) + { +
+ + +
+ } + +
+ +
+ } +
+
+ } + + @if (currentStep == 1) + { +
+
+ Capture kinds + +
+
+ @foreach (var captureKind in SupportedCaptureKinds) + { + + } +
+
+ } + + @if (currentStep == 2) + { +
+
+ Targets + +
+
+ @if (!string.IsNullOrWhiteSpace(CurrentPlatformNotes)) + { +
@CurrentPlatformNotes
+ } + + @if (CurrentScenarioDefinition is not null) + { +
+
@CurrentScenarioDefinition.DisplayName
+
@CurrentScenarioDefinition.Description
+
+ Suggested duration: @CurrentScenarioDefinition.SuggestedDuration.ToString(@"hh\:mm\:ss") +
+
+ } + + @if (TargetOptions.Count == 0) + { +
+ +
No targets are currently selectable for @GetPlatformDisplayName(selectedPlatform).
+ @if (selectedPlatform == ProfilingTargetPlatform.Android && knownAndroidEmulatorCount > 0) + { +
You have @knownAndroidEmulatorCount Android emulator definition(s). Start one from the Emulators page to profile it here.
+ } + @if (selectedPlatform == ProfilingTargetPlatform.iOS && knownAppleSimulatorCount > 0) + { +
You have @knownAppleSimulatorCount available Apple simulator(s). Select one after refreshing if it does not appear yet.
+ } + +
+ } + else + { +
+ @foreach (var option in TargetOptions) + { + + } +
+ } +
+
+ } + + @if (currentStep == 3) + { + @if (isCheckingPrerequisites || isPlanningCapture) + { +
+ + + @if (isCheckingPrerequisites) + { + @:Checking prerequisites... + } + else + { + @:Generating capture plan... + } + +
+ } + + @if (prerequisiteReport is not null) + { +
+
+ Prerequisites +
+ @prerequisiteReport.OkCount ok + @prerequisiteReport.WarningCount warning + @prerequisiteReport.ErrorCount error +
+
+
+
+ @foreach (var check in prerequisiteReport.Checks) + { +
+
+ @check.Name + @check.Status +
+
@check.Message
+
+ @check.Kind + @if (!string.IsNullOrWhiteSpace(check.InstalledVersion)) + { + Installed: @check.InstalledVersion + } + @if (!string.IsNullOrWhiteSpace(check.RequiredVersion)) + { + Required: @check.RequiredVersion + } +
+ @if (!string.IsNullOrWhiteSpace(check.SuggestedCommand)) + { + @check.SuggestedCommand + } +
+ } +
+
+
+ } + + @if (capturePlan is not null) + { +
+
+ Capture plan +
+ + @(capturePlan.Validation.IsValid ? "Valid" : "Needs attention") + + + @(capturePlan.CanExecute ? "Executable" : "Preview only") + +
+
+
+
+
+
Target framework
+
@capturePlan.TargetFramework
+
+
+
Output directory
+
@capturePlan.OutputDirectory
+
+
+
Command steps
+
@capturePlan.Commands.Count
+
+
+
Artifacts
+
@capturePlan.ExpectedArtifacts.Count
+
+
+ + @if (capturePlan.Validation.Errors.Count > 0) + { +
+
Errors
+
    + @foreach (var error in capturePlan.Validation.Errors) + { +
  • @error
  • + } +
+
+ } + + @if (capturePlan.Validation.Warnings.Count > 0) + { +
+
Warnings
+
    + @foreach (var warning in capturePlan.Validation.Warnings) + { +
  • @warning
  • + } +
+
+ } + + @if (capturePlan.RuntimeBindings.Count > 0) + { +
+
Runtime bindings
+
    + @foreach (var binding in capturePlan.RuntimeBindings) + { +
  • + @binding.Token — @binding.Description + @if (!string.IsNullOrWhiteSpace(binding.ExampleValue)) + { + (example: @binding.ExampleValue) + } +
  • + } +
+
+ } + +
Commands
+
+ @foreach (var step in capturePlan.Commands) + { +
+
+
+
@step.DisplayName
+
@step.Description
+
+
+ @step.Kind + @if (step.IsLongRunning) + { + Long-running + } + @if (step.RequiresManualStop) + { + Manual stop + } +
+
+ @BuildCommandLine(step) + @if (step.DependsOn?.Count > 0 || step.RequiredRuntimeBindings?.Count > 0) + { +
+ @if (step.DependsOn?.Count > 0) + { + Depends on: @string.Join(", ", step.DependsOn) + } + @if (step.RequiredRuntimeBindings?.Count > 0) + { + Needs: @string.Join(", ", step.RequiredRuntimeBindings) + } +
+ } +
+ +
+
+ } +
+ +
Expected artifacts
+
+ @foreach (var artifact in capturePlan.ExpectedArtifacts) + { +
+
@artifact.DisplayName
+
@artifact.RelativePath
+
@artifact.Kind · @artifact.ContentType
+
+ } +
+
+
+ } + } + + @if (currentStep == 4) + { +
+
+ Capture +
+ @if (pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) + { + @FormatElapsed(pipelineElapsed) + } +
+
+
+ @if (pipelineState == ProfilingPipelineState.NotStarted) + { +
+ +
Starting capture pipeline...
+
+ } + else + { +
+ + @GetPipelineStateText() +
+ +
+ @foreach (var stepStatus in PipelineRunner.Steps) + { +
+
+
+ @switch (stepStatus.State) + { + case ProfilingStepState.Running: + + break; + case ProfilingStepState.Completed: + + break; + case ProfilingStepState.Failed: + + break; + case ProfilingStepState.Stopped: + + break; + case ProfilingStepState.Skipped: + + break; + case ProfilingStepState.Cancelled: + + break; + default: + + break; + } +
+
+
@stepStatus.DisplayName
+
+ @stepStatus.Kind + @if (stepStatus.IsLongRunning) + { + Long-running + } + @if (stepStatus.Duration.HasValue) + { + @FormatElapsed(stepStatus.Duration.Value) + } + else if (stepStatus.State == ProfilingStepState.Running && stepStatus.StartedAt.HasValue) + { + @FormatElapsed(DateTime.Now - stepStatus.StartedAt.Value) + } +
+
+
+ +
+
+ @if (stepStatus.ErrorMessage is not null && stepStatus.State == ProfilingStepState.Failed) + { +
+ @stepStatus.ErrorMessage +
+ } + @if (expandedStepLogs.Contains(stepStatus.StepId)) + { +
+ @if (stepStatus.OutputLines.Count == 0) + { +
No output yet
+ } + else + { + @foreach (var line in stepStatus.OutputLines.TakeLast(200)) + { +
@line.Text
+ } + } +
+ } +
+ } +
+ } +
+
+ + @if (pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) + { +
+ @if (pipelineState == ProfilingPipelineState.Running) + { +
+ + Launching capture pipeline... +
+ } + else + { + + } + +
+ } + } +
+ } +
+} + +@code { + private ProfilingCatalog? catalog; + private ConnectedDevicesSnapshot snapshot = ConnectedDevicesSnapshot.Empty; + private List androidEmulators = new(); + private List appleSimulators = new(); + private List targetOptions = new(); + private HashSet selectedCaptureKinds = new(); + private ProfilingPrerequisiteReport? prerequisiteReport; + private ProfilingCapturePlan? capturePlan; + private string? selectedTargetKey; + private string? projectPath; + private string? targetFrameworkOverride; + private string? outputDirectory; + private string? processIdText; + private string configuration = "Release"; + private int diagnosticPort = 9000; + private bool suspendAtStartup = true; + private bool isRefreshingTargets; + private bool isCheckingPrerequisites; + private bool isPlanningCapture; + private int knownAndroidEmulatorCount; + private int knownAppleSimulatorCount; + private ProfilingTargetPlatform selectedPlatform = ProfilingTargetPlatform.Android; + private ProfilingScenarioKind selectedScenario = ProfilingScenarioKind.Launch; + private ProfilingCaptureLaunchMode launchMode = ProfilingCaptureLaunchMode.Launch; + + private int currentStep = 0; + private bool showAdvanced = false; + + // Pipeline state + private ProfilingPipelineState pipelineState = ProfilingPipelineState.NotStarted; + private ProfilingPipelineResult? pipelineResult; + private TimeSpan pipelineElapsed; + private System.Threading.Timer? pipelineTimer; + private DateTime pipelineStartTime; + private HashSet expandedStepLogs = new(); + + private string? activeSessionId; + + private static readonly string[] WizardStepLabels = new[] + { + "Session Setup", + "Capture Kinds", + "Target", + "Review & Plan", + "Capture" + }; + + private bool CanProceedToNext => currentStep switch + { + 0 => launchMode == ProfilingCaptureLaunchMode.Attach || !string.IsNullOrWhiteSpace(projectPath), + 1 => selectedCaptureKinds.Count > 0, + 2 => SelectedTarget is not null, + 3 => capturePlan is not null && capturePlan.Validation.IsValid, + _ => false + }; + + private IReadOnlyList TargetOptions => targetOptions; + private TargetOption? SelectedTarget => targetOptions.FirstOrDefault(option => option.Key == selectedTargetKey); + private ProfilingPlatformCapabilities? CurrentPlatformCapabilities => catalog?.Platforms.FirstOrDefault(platform => platform.Platform == selectedPlatform); + private ProfilingScenarioDefinition? CurrentScenarioDefinition => catalog?.Scenarios.FirstOrDefault(scenario => scenario.Kind == selectedScenario); + private IReadOnlyList SupportedCaptureKinds => CurrentPlatformCapabilities?.SupportedCaptureKinds ?? Array.Empty(); + private string? CurrentPlatformNotes => CurrentPlatformCapabilities?.Notes; + + protected override async Task OnInitializedAsync() + { + var bridge = BridgeHolder.Current; + if (bridge == null) return; + + bridge.SubmitRequested += OnSubmitRequested; + bridge.BackRequested += OnBackRequested; + bridge.NextRequested += OnNextRequested; + + DeviceMonitor.Changed += OnDeviceMonitorChanged; + + catalog = await ProfilingCatalogService.GetCatalogAsync(); + selectedPlatform = catalog.Platforms.FirstOrDefault(platform => platform.Platform == ProfilingTargetPlatform.Android)?.Platform + ?? catalog.Platforms.First().Platform; + selectedScenario = catalog.Scenarios.FirstOrDefault(scenario => scenario.Kind == ProfilingScenarioKind.Launch)?.Kind + ?? catalog.Scenarios.First().Kind; + ResetCaptureKindsToScenarioDefaults(); + + await RefreshTargetsAsync(); + UpdateWizardState(); + } + + private void UpdateWizardState() + { + var bridge = BridgeHolder.Current; + if (bridge == null) return; + + var isCapturing = currentStep == 4 && pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop or ProfilingPipelineState.Completing; + + bridge.SetWizardState( + showBack: currentStep > 0 && currentStep != 4, + showNext: currentStep < 3, + showSubmit: currentStep == 3, + canProceed: CanProceedToNext && !isCapturing, + submitText: "Start Capture" + ); + } + + private async Task OnNextRequested() + { + if (!CanProceedToNext) return; + + if (currentStep == 2) + { + currentStep = 3; + await InvokeAsync(StateHasChanged); + await CheckPrerequisitesAsync(); + if (SelectedTarget is not null) + await GeneratePlanAsync(); + } + else + { + currentStep = Math.Min(currentStep + 1, 4); + } + + UpdateWizardState(); + await InvokeAsync(StateHasChanged); + } + + private Task OnBackRequested() + { + if (currentStep > 0 && currentStep != 4) + { + currentStep--; + UpdateWizardState(); + InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + private async Task OnSubmitRequested() + { + if (currentStep == 3 && CanProceedToNext) + { + // Start Capture — advance to step 4 and run the pipeline + currentStep = 4; + UpdateWizardState(); + await InvokeAsync(StateHasChanged); + await StartPipelineAsync(); + } + } + + private void OnDeviceMonitorChanged(ConnectedDevicesSnapshot updatedSnapshot) + { + snapshot = updatedSnapshot; + BuildTargetOptions(); + InvokeAsync(StateHasChanged); + } + + private async Task RefreshTargetsAsync(bool forceRefresh = false) + { + isRefreshingTargets = true; + StateHasChanged(); + + try + { + if (forceRefresh) + { + await Mediator.FlushStores("android:emulators"); + await Mediator.FlushStores("apple:simulators"); + } + + var emuTask = Mediator.Request(new GetEmulatorsRequest()); + var simTask = Mediator.Request(new GetSimulatorsRequest()); + + await Task.WhenAll(emuTask, simTask); + + var (_, emuResult) = await emuTask; + androidEmulators = emuResult?.ToList() ?? new(); + knownAndroidEmulatorCount = androidEmulators.Count; + + var (_, simResult) = await simTask; + appleSimulators = simResult?.Where(simulator => simulator.IsAvailable).ToList() ?? new(); + knownAppleSimulatorCount = appleSimulators.Count; + + snapshot = DeviceMonitor.Current; + BuildTargetOptions(); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Error refreshing profiling targets: {ex.Message}"); + } + finally + { + isRefreshingTargets = false; + StateHasChanged(); + } + } + + private Task OnPlatformChangedAsync() + { + ResetCaptureKindsToScenarioDefaults(); + BuildTargetOptions(); + prerequisiteReport = null; + capturePlan = null; + UpdateWizardState(); + return Task.CompletedTask; + } + + private Task OnScenarioChangedAsync() + { + ResetCaptureKindsToScenarioDefaults(); + prerequisiteReport = null; + capturePlan = null; + UpdateWizardState(); + return Task.CompletedTask; + } + + private Task OnLaunchModeChangedAsync() + { + capturePlan = null; + UpdateWizardState(); + return Task.CompletedTask; + } + + private void ResetCaptureKindsToScenarioDefaults() + { + var defaults = CurrentScenarioDefinition?.DefaultCaptureKinds + .Where(kind => SupportedCaptureKinds.Contains(kind)) + .ToArray(); + + selectedCaptureKinds = defaults is { Length: > 0 } + ? defaults.ToHashSet() + : SupportedCaptureKinds.ToHashSet(); + } + + private void SelectTarget(string key) + { + selectedTargetKey = key; + capturePlan = null; + } + + private void OnCaptureKindChanged(ProfilingCaptureKind kind, ChangeEventArgs args) + { + var isSelected = args.Value as bool? == true; + if (isSelected) + selectedCaptureKinds.Add(kind); + else if (selectedCaptureKinds.Count > 1) + selectedCaptureKinds.Remove(kind); + + capturePlan = null; + } + + private async Task BrowseProjectAsync() + { + var selectedPath = await DialogService.PickOpenFileAsync("Select project", new[] { ".csproj" }); + if (!string.IsNullOrWhiteSpace(selectedPath)) + { + projectPath = selectedPath; + capturePlan = null; + UpdateWizardState(); + StateHasChanged(); + } + } + + private async Task CheckPrerequisitesAsync() + { + isCheckingPrerequisites = true; + StateHasChanged(); + + try + { + var captureKinds = GetSelectedCaptureKinds(); + var (_, report) = await Mediator.Request(new GetProfilingPrerequisitesRequest(selectedPlatform, captureKinds)); + prerequisiteReport = report; + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Unable to load profiling prerequisites: {ex.Message}"); + } + finally + { + isCheckingPrerequisites = false; + StateHasChanged(); + } + } + + private async Task GeneratePlanAsync() + { + if (SelectedTarget is null) + { + await AlertService.ShowToastAsync("Select a profiling target first."); + return; + } + + isPlanningCapture = true; + StateHasChanged(); + + try + { + var definition = ProfilingCatalogService.CreateSessionDefinition( + SelectedTarget.ToProfilingTarget(), + selectedScenario, + captureKinds: GetSelectedCaptureKinds()); + + var (_, plan) = await Mediator.Request(new PlanProfilingCaptureRequest(definition, BuildPlanOptions(SelectedTarget))); + capturePlan = plan; + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Unable to generate profiling plan: {ex.Message}"); + } + finally + { + isPlanningCapture = false; + UpdateWizardState(); + StateHasChanged(); + } + } + + private ProfilingCapturePlanOptions BuildPlanOptions(TargetOption selectedTarget) + { + var additionalBuildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (selectedTarget.Platform == ProfilingTargetPlatform.Android && + selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Emulator) + { + additionalBuildProperties["AdbTarget"] = $"-s {selectedTarget.Identifier}"; + } + + if (selectedTarget.Platform == ProfilingTargetPlatform.iOS && + selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Simulator) + { + additionalBuildProperties["_DeviceName"] = $":v2:udid={selectedTarget.Identifier}"; + } + + int? processId = null; + if (int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) + processId = parsedProcessId; + + return new ProfilingCapturePlanOptions( + ProjectPath: string.IsNullOrWhiteSpace(projectPath) ? null : projectPath, + Configuration: configuration, + TargetFramework: string.IsNullOrWhiteSpace(targetFrameworkOverride) ? null : targetFrameworkOverride, + OutputDirectory: string.IsNullOrWhiteSpace(outputDirectory) ? null : outputDirectory, + LaunchMode: launchMode, + DiagnosticPort: diagnosticPort, + SuspendAtStartup: suspendAtStartup, + ProcessId: processId, + AdditionalBuildProperties: additionalBuildProperties.Count == 0 ? null : additionalBuildProperties); + } + + private void BuildTargetOptions() + { + var options = new List(); + + switch (selectedPlatform) + { + case ProfilingTargetPlatform.Android: + options.AddRange(snapshot.AndroidDevices.Select(device => new TargetOption( + Key: $"android-device:{device.Serial}", + Platform: ProfilingTargetPlatform.Android, + Kind: ProfilingTargetKind.PhysicalDevice, + Identifier: device.Serial, + DisplayName: device.Model ?? device.Serial, + Subtitle: "Connected Android device", + KindLabel: "Device", + IsAvailable: true, + ExtraHint: device.State))); + + options.AddRange(snapshot.AndroidEmulators.Select(device => new TargetOption( + Key: $"android-emulator:{device.Serial}", + Platform: ProfilingTargetPlatform.Android, + Kind: ProfilingTargetKind.Emulator, + Identifier: device.Serial, + DisplayName: device.Model ?? device.Serial, + Subtitle: "Running Android emulator", + KindLabel: "Emulator", + IsAvailable: true, + ExtraHint: device.State))); + break; + + case ProfilingTargetPlatform.iOS: + options.AddRange(snapshot.ApplePhysicalDevices.Select(device => new TargetOption( + Key: $"ios-device:{device.Identifier}", + Platform: ProfilingTargetPlatform.iOS, + Kind: ProfilingTargetKind.PhysicalDevice, + Identifier: device.Identifier, + DisplayName: device.Name, + Subtitle: $"{device.ModelName} · iOS {device.OsVersion}", + KindLabel: "Device", + IsAvailable: device.IsAvailable, + ExtraHint: device.Interface))); + + options.AddRange(appleSimulators.Select(simulator => new TargetOption( + Key: $"ios-simulator:{simulator.Udid}", + Platform: ProfilingTargetPlatform.iOS, + Kind: ProfilingTargetKind.Simulator, + Identifier: simulator.Udid, + DisplayName: simulator.Name, + Subtitle: $"{simulator.Runtime ?? "Unknown runtime"} · {simulator.ProductFamily ?? "Simulator"}", + KindLabel: "Simulator", + IsAvailable: string.Equals(simulator.State, "Booted", StringComparison.OrdinalIgnoreCase), + ExtraHint: simulator.State))); + break; + + case ProfilingTargetPlatform.MacCatalyst: + if (Platform.IsMacCatalyst || Platform.IsMacOS) + { + options.Add(new TargetOption( + Key: "desktop:maccatalyst", + Platform: ProfilingTargetPlatform.MacCatalyst, + Kind: ProfilingTargetKind.Desktop, + Identifier: "local-maccatalyst", + DisplayName: "This Mac", + Subtitle: "Local Mac Catalyst target", + KindLabel: "Desktop", + IsAvailable: true)); + } + break; + + case ProfilingTargetPlatform.MacOS: + if (Platform.IsMacOS) + { + options.Add(new TargetOption( + Key: "desktop:macos", + Platform: ProfilingTargetPlatform.MacOS, + Kind: ProfilingTargetKind.Desktop, + Identifier: "local-macos", + DisplayName: "This Mac", + Subtitle: "Local macOS target", + KindLabel: "Desktop", + IsAvailable: true)); + } + break; + + case ProfilingTargetPlatform.Windows: + if (Platform.IsWindows) + { + options.Add(new TargetOption( + Key: "desktop:windows", + Platform: ProfilingTargetPlatform.Windows, + Kind: ProfilingTargetKind.Desktop, + Identifier: "local-windows", + DisplayName: "This PC", + Subtitle: "Local Windows target", + KindLabel: "Desktop", + IsAvailable: true)); + } + break; + } + + targetOptions = options; + if (selectedTargetKey is null || targetOptions.All(option => option.Key != selectedTargetKey)) + selectedTargetKey = targetOptions.FirstOrDefault()?.Key; + } + + private IReadOnlyList GetSelectedCaptureKinds() + => selectedCaptureKinds.Count > 0 + ? selectedCaptureKinds.OrderBy(kind => kind).ToArray() + : SupportedCaptureKinds; + + private async Task StartPipelineAsync() + { + if (capturePlan is null) return; + + var projectName = projectPath is not null + ? Path.GetFileNameWithoutExtension(projectPath) + : null; + activeSessionId = SessionStorage.GenerateSessionId(projectName); + var sessionDir = SessionStorage.GetSessionDirectoryPath(activeSessionId); + + capturePlan = capturePlan with { OutputDirectory = sessionDir }; + + pipelineState = ProfilingPipelineState.NotStarted; + pipelineResult = null; + expandedStepLogs.Clear(); + pipelineStartTime = DateTime.Now; + StateHasChanged(); + + PipelineRunner.PipelineStateChanged += OnPipelineStateChanged; + PipelineRunner.StepStateChanged += OnStepStateChanged; + PipelineRunner.StepOutputReceived += OnStepOutputReceived; + + pipelineTimer = new System.Threading.Timer(_ => + { + pipelineElapsed = DateTime.Now - pipelineStartTime; + InvokeAsync(() => + { + UpdateWizardState(); + StateHasChanged(); + }); + }, null, TimeSpan.Zero, TimeSpan.FromSeconds(1)); + + try + { + pipelineResult = await PipelineRunner.RunAsync(capturePlan); + pipelineState = pipelineResult.FinalState; + + // Save session and set bridge result + var manifest = await SaveSessionAsync(); + if (manifest is not null) + { + var bridge = BridgeHolder.Current; + if (bridge != null) + bridge.Result = manifest; + } + } + catch (Exception ex) + { + pipelineState = ProfilingPipelineState.Failed; + await AlertService.ShowToastAsync($"Pipeline failed: {ex.Message}"); + } + finally + { + pipelineTimer?.Dispose(); + pipelineTimer = null; + PipelineRunner.PipelineStateChanged -= OnPipelineStateChanged; + PipelineRunner.StepStateChanged -= OnStepStateChanged; + PipelineRunner.StepOutputReceived -= OnStepOutputReceived; + UpdateWizardState(); + StateHasChanged(); + } + } + + private async Task SaveSessionAsync() + { + if (pipelineResult is null || capturePlan is null) return null; + + try + { + var projectName = projectPath is not null + ? Path.GetFileNameWithoutExtension(projectPath) + : null; + var sessionId = activeSessionId ?? SessionStorage.GenerateSessionId(projectName); + activeSessionId = sessionId; + + var target = SelectedTarget; + var manifest = new ProfilingSessionManifest + { + Id = sessionId, + Name = $"{projectName ?? "Session"} — {selectedPlatform} {target?.DisplayName ?? "Unknown"}", + Status = pipelineResult.Success ? ProfilingSessionStatus.Completed + : (pipelineState == ProfilingPipelineState.Cancelled ? ProfilingSessionStatus.Cancelled + : ProfilingSessionStatus.Failed), + CreatedAt = pipelineStartTime, + CompletedAt = DateTimeOffset.UtcNow, + Target = new ProfilingSessionTarget + { + Platform = selectedPlatform, + Kind = target?.Kind ?? ProfilingTargetKind.Desktop, + Identifier = target?.Identifier ?? "", + DisplayName = target?.DisplayName ?? selectedPlatform.ToString() + }, + Project = projectPath is not null ? new ProfilingSessionProject + { + Path = projectPath, + Name = projectName!, + Configuration = configuration, + TargetFramework = targetFrameworkOverride + } : null, + CaptureKinds = selectedCaptureKinds.ToList(), + Options = new ProfilingSessionOptions + { + LaunchMode = launchMode, + DiagnosticPort = diagnosticPort, + SuspendAtStartup = suspendAtStartup, + ProcessId = int.TryParse(processIdText, out var pid) ? pid : null, + Scenario = selectedScenario + }, + Pipeline = new ProfilingSessionPipelineSummary + { + Success = pipelineResult.Success, + Duration = pipelineResult.TotalDuration, + Steps = pipelineResult.StepResults.Select(step => new ProfilingSessionStepSummary + { + Id = step.StepId, + DisplayName = step.DisplayName, + State = step.State.ToString(), + CommandLine = capturePlan.Commands.FirstOrDefault(c => c.Id == step.StepId)?.CommandLine + }).ToList() + }, + Artifacts = new List() + }; + + var sessionDir = SessionStorage.GetSessionDirectoryPath(sessionId); + foreach (var artifactPath in pipelineResult.ArtifactPaths) + { + var fileName = Path.GetFileName(artifactPath); + var extension = GetArtifactExtension(fileName); + var kind = extension switch + { + ".nettrace" => ProfilingArtifactKind.Trace, + ".speedscope.json" => ProfilingArtifactKind.Export, + ".gcdump" => ProfilingArtifactKind.GcDump, + ".log" => ProfilingArtifactKind.Log, + _ => ProfilingArtifactKind.Other + }; + + long? size = null; + try { if (File.Exists(artifactPath)) size = new FileInfo(artifactPath).Length; } catch { } + + manifest.Artifacts.Add(new ProfilingSessionArtifact + { + FileName = fileName, + Kind = kind, + SizeBytes = size + }); + } + + if (Directory.Exists(sessionDir)) + { + foreach (var logFile in Directory.GetFiles(sessionDir, "*.log")) + { + var logName = Path.GetFileName(logFile); + if (manifest.Artifacts.Any(a => a.FileName == logName)) continue; + long? size = null; + try { size = new FileInfo(logFile).Length; } catch { } + manifest.Artifacts.Add(new ProfilingSessionArtifact + { + FileName = logName, + Kind = ProfilingArtifactKind.Log, + SizeBytes = size + }); + } + } + + await SessionStorage.SaveSessionAsync(manifest); + return manifest; + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Failed to save session: {ex.Message}"); + return null; + } + } + + private void OnPipelineStateChanged(object? sender, ProfilingPipelineStateChangedEventArgs e) + { + pipelineState = e.NewState; + InvokeAsync(() => + { + UpdateWizardState(); + StateHasChanged(); + }); + } + + private void OnStepStateChanged(object? sender, ProfilingStepStateChangedEventArgs e) + { + if (e.NewState == ProfilingStepState.Running) + { + expandedStepLogs.Add(e.StepId); + InvokeAsync(async () => + { + StateHasChanged(); + await Task.Yield(); + try { await JS.InvokeVoidAsync("logScrollInterop.track", $"step-log-{e.StepId}"); } catch { } + }); + return; + } + InvokeAsync(StateHasChanged); + } + + private void OnStepOutputReceived(object? sender, ProfilingStepOutputEventArgs e) + { + InvokeAsync(async () => + { + StateHasChanged(); + await Task.Yield(); + try { await JS.InvokeVoidAsync("logScrollInterop.scrollToBottom", $"step-log-{e.StepId}"); } catch { } + }); + } + + private async Task HandleStopCapture() + { + await PipelineRunner.StopCaptureAsync(); + } + + private void HandleCancelPipeline() + { + PipelineRunner.Cancel(); + pipelineState = ProfilingPipelineState.Cancelled; + currentStep = 3; + UpdateWizardState(); + StateHasChanged(); + } + + private async void ToggleStepLog(string stepId) + { + if (!expandedStepLogs.Remove(stepId)) + { + expandedStepLogs.Add(stepId); + StateHasChanged(); + await Task.Yield(); + try + { + await JS.InvokeVoidAsync("logScrollInterop.track", $"step-log-{stepId}"); + await JS.InvokeVoidAsync("logScrollInterop.scrollToBottom", $"step-log-{stepId}"); + } + catch { } + } + else + { + try { await JS.InvokeVoidAsync("logScrollInterop.untrack", $"step-log-{stepId}"); } catch { } + } + } + + private string GetPipelineStateIcon() => pipelineState switch + { + ProfilingPipelineState.Running => "fa-spinner fa-spin", + ProfilingPipelineState.WaitingForStop => "fa-circle-dot fa-beat-fade", + ProfilingPipelineState.Completing => "fa-spinner fa-spin", + ProfilingPipelineState.Completed => "fa-check-circle", + ProfilingPipelineState.Failed => "fa-times-circle", + ProfilingPipelineState.Cancelled => "fa-ban", + _ => "fa-circle" + }; + + private string GetPipelineStateText() => pipelineState switch + { + ProfilingPipelineState.Running => "Launching capture pipeline...", + ProfilingPipelineState.WaitingForStop => "Recording — click Stop Capture when ready", + ProfilingPipelineState.Completing => "Stopping capture and collecting artifacts...", + ProfilingPipelineState.Completed => "Capture completed successfully", + ProfilingPipelineState.Failed => "Capture failed — check step details below", + ProfilingPipelineState.Cancelled => "Capture cancelled", + _ => "Initializing..." + }; + + private static string FormatElapsed(TimeSpan ts) => + ts.TotalMinutes >= 1 ? $"{(int)ts.TotalMinutes}m {ts.Seconds}s" : $"{ts.Seconds}s"; + + private static string GetPlatformDisplayName(ProfilingTargetPlatform platform) => platform switch + { + ProfilingTargetPlatform.iOS => "iOS", + ProfilingTargetPlatform.MacCatalyst => "Mac Catalyst", + ProfilingTargetPlatform.MacOS => "macOS", + _ => platform.ToString() + }; + + private static string GetCaptureKindDisplayName(ProfilingCaptureKind captureKind) => captureKind switch + { + ProfilingCaptureKind.Cpu => "CPU trace", + ProfilingCaptureKind.Memory => "Memory / GC dump", + ProfilingCaptureKind.Network => "Network", + ProfilingCaptureKind.Rendering => "Rendering", + ProfilingCaptureKind.Energy => "Energy", + ProfilingCaptureKind.SystemTrace => "System trace", + ProfilingCaptureKind.Logs => "Logs", + ProfilingCaptureKind.Startup => "Startup", + _ => captureKind.ToString() + }; + + private static string GetDependencyCss(DependencyStatusType status) => status switch + { + DependencyStatusType.Ok => "ok", + DependencyStatusType.Info => "ok", + DependencyStatusType.Warning => "warning", + DependencyStatusType.Error => "error", + _ => "neutral" + }; + + private async Task CopyCommandAsync(ProfilingCommandStep step) + { + await DialogService.CopyToClipboardAsync(BuildCommandLine(step)); + await AlertService.ShowToastAsync("Copied command to clipboard."); + } + + private string BuildCommandLine(ProfilingCommandStep step) + { + var resolved = ResolveCommandStep(step); + return resolved?.CommandLine ?? step.CommandLine; + } + + private ProcessRequest? ResolveCommandStep(ProfilingCommandStep step) + { + if (step.RequiredRuntimeBindings is not null) + { + foreach (var binding in step.RequiredRuntimeBindings) + { + if (!TryResolveRuntimeBinding(binding, out _)) + return null; + } + } + + var resolvedArguments = step.Arguments.Select(ResolveRuntimeTokens).ToArray(); + var resolvedEnvironment = step.Environment?.ToDictionary( + kvp => kvp.Key, + kvp => ResolveRuntimeTokens(kvp.Value), + StringComparer.OrdinalIgnoreCase); + + return new ProcessRequest( + step.Command, + resolvedArguments, + step.WorkingDirectory, + Environment: resolvedEnvironment, + Title: step.DisplayName, + Description: step.Description); + } + + private string ResolveRuntimeTokens(string value) + { + foreach (var binding in capturePlan?.RuntimeBindings ?? Array.Empty()) + { + if (TryResolveRuntimeBinding(binding.Token, out var resolvedValue)) + value = value.Replace(binding.Token, resolvedValue, StringComparison.Ordinal); + } + + return value; + } + + private bool TryResolveRuntimeBinding(string token, out string value) + { + if (token == "{{PROCESS_ID}}" && int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) + { + value = parsedProcessId.ToString(); + return true; + } + + value = string.Empty; + return false; + } + + private static string GetArtifactExtension(string path) + { + if (path.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + return ".speedscope.json"; + return Path.GetExtension(path).ToLowerInvariant(); + } + + public void Dispose() + { + var bridge = BridgeHolder.Current; + if (bridge != null) + { + bridge.SubmitRequested -= OnSubmitRequested; + bridge.BackRequested -= OnBackRequested; + bridge.NextRequested -= OnNextRequested; + } + + DeviceMonitor.Changed -= OnDeviceMonitorChanged; + pipelineTimer?.Dispose(); + } + + private sealed record TargetOption( + string Key, + ProfilingTargetPlatform Platform, + ProfilingTargetKind Kind, + string Identifier, + string DisplayName, + string Subtitle, + string KindLabel, + bool IsAvailable, + string? ExtraHint = null) + { + public ProfilingTarget ToProfilingTarget() => new( + Platform, + Kind, + Identifier, + DisplayName); + } +} + + diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index bbb9f94f..65715cad 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -1,22 +1,16 @@ @page "/profiling" @using MauiSherpa.Core.Interfaces @using MauiSherpa.Core.Models.Profiling -@using MauiSherpa.Core.Requests.Android -@using MauiSherpa.Core.Requests.Apple -@using MauiSherpa.Core.Requests.Profiling -@using Shiny.Mediator -@inject IMediator Mediator -@inject IProfilingCatalogService ProfilingCatalogService -@inject IDeviceMonitorService DeviceMonitor -@inject IDialogService DialogService +@using MauiSherpa.Pages.Forms @inject IAlertService AlertService @inject IToolbarService ToolbarService +@inject IDialogService DialogService @inject IPlatformService Platform -@inject IProcessModalService ProcessModal -@inject IProfilingSessionRunner PipelineRunner @inject IGcDumpReportService GcDumpReportService @inject IProfilingArtifactConverterService ArtifactConverter @inject IProfilingSessionStorageService SessionStorage +@inject IFormModalService FormModal +@inject HybridFormBridgeHolder BridgeHolder @inject IJSRuntime JS @implements IDisposable @@ -31,9 +25,7 @@

-@if (viewMode == ViewMode.SessionList) -{ - @if (isLoadingSessions) +@if (isLoadingSessions) {
@@ -46,7 +38,7 @@

No profiling sessions yet

Capture CPU traces, memory dumps, and more for your .NET MAUI apps.

-
@@ -301,2888 +293,632 @@ } } } -else if (viewMode == ViewMode.Wizard) -{ - @if (catalog is null) - { -
- - Loading profiling catalog... -
- } - else - { -
- @for (int i = 0; i < WizardSteps.Length; i++) - { - var step = WizardSteps[i]; - var stepIndex = i; - var stepClass = stepIndex == currentStep ? "active" : (stepIndex < currentStep ? "completed" : "upcoming"); -
-
- @if (stepIndex < currentStep) - { - - } - else - { - @(stepIndex + 1) - } -
- @step.Name -
- @if (i < WizardSteps.Length - 1) - { -
- } - } -
- - @if (currentStep == 0) - { -
-
- Session setup -
-
-
- -
- - -
-
- Launch planning needs a project path. Connect-to-running-app flows can omit it if you provide a process id. -
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - Advanced settings -
- @if (showAdvanced) - { -
- - -
+@code { + private List sessions = new(); + private List filteredSessions = new(); + private bool isLoadingSessions; + private string sessionSearchText = ""; + private string? expandedSessionId; -
- - -
+ // Speedscope viewer state + private bool showSpeedscopeViewer; + private bool speedscopeLoading; + private string? speedscopeFilePath; + private IJSObjectReference? speedscopeModule; -
- - -
+ // GC dump viewer state + private bool showGcDumpViewer; + private bool gcDumpLoading; + private string? gcDumpError; + private GcDumpReport? gcDumpReport; + private string gcDumpFilterText = ""; + private string gcDumpSortColumn = "size"; + private bool gcDumpSortAsc = false; - @if (launchMode == ProfilingCaptureLaunchMode.Attach) - { -
- - -
- } + protected override async Task OnInitializedAsync() + { + SetupToolbar(); + ToolbarService.ToolbarItemClicked += OnToolbarItemClicked; + ToolbarService.SearchTextChanged += OnSearchTextChanged; + await LoadSessionsAsync(); + } -
- -
- } -
-
+ private void OnToolbarItemClicked(string actionId) + { + if (actionId == "create") + _ = InvokeAsync(StartNewWizardAsync); + else if (actionId == "import") + _ = ImportSessionFromZipAsync(); + else if (actionId == "refresh") + _ = LoadSessionsAsync(); } - @if (currentStep == 1) + private void OnSearchTextChanged(string text) { -
-
- Capture kinds - -
-
- @foreach (var captureKind in SupportedCaptureKinds) - { - - } -
-
+ sessionSearchText = text; + ApplySessionFilter(); + InvokeAsync(StateHasChanged); } - @if (currentStep == 2) + private async Task StartNewWizardAsync() { -
-
- Targets - -
-
- @if (!string.IsNullOrWhiteSpace(CurrentPlatformNotes)) - { -
@CurrentPlatformNotes
- } + var page = new MauiSherpa.Pages.Modals.ProfilingCaptureWizardPage(BridgeHolder); + var result = await FormModal.ShowAsync(page); - @if (CurrentScenarioDefinition is not null) - { -
-
@CurrentScenarioDefinition.DisplayName
-
@CurrentScenarioDefinition.Description
-
- Suggested duration: @CurrentScenarioDefinition.SuggestedDuration.ToString(@"hh\:mm\:ss") -
-
- } + if (result is not null) + { + await LoadSessionsAsync(); + expandedSessionId = result.Id; + StateHasChanged(); + } + } - @if (TargetOptions.Count == 0) - { -
- -
No targets are currently selectable for @GetPlatformDisplayName(selectedPlatform).
- @if (selectedPlatform == ProfilingTargetPlatform.Android && knownAndroidEmulatorCount > 0) - { -
You have @knownAndroidEmulatorCount Android emulator definition(s). Start one from the Emulators page to profile it here.
- } - @if (selectedPlatform == ProfilingTargetPlatform.iOS && knownAppleSimulatorCount > 0) - { -
You have @knownAppleSimulatorCount available Apple simulator(s). Select one after refreshing if it does not appear yet.
- } - -
- } - else - { -
- @foreach (var option in TargetOptions) - { - - } -
- } -
-
+ private void SetupToolbar() + { + ToolbarService.ClearItems(); + ToolbarService.SetItems( + new ToolbarAction("create", "New Session", "plus"), + new ToolbarAction("import", "Import", "square.and.arrow.down"), + new ToolbarAction("refresh", "Refresh", "arrow.clockwise") + ); + ToolbarService.SetSearch("Search sessions..."); } - @if (currentStep == 3) + private async Task LoadSessionsAsync() { - @if (isCheckingPrerequisites || isPlanningCapture) + isLoadingSessions = true; + StateHasChanged(); + + try { -
- - - @if (isCheckingPrerequisites) - { - @:Checking prerequisites... - } - else - { - @:Generating capture plan... - } - -
+ sessions = (await SessionStorage.GetSessionsAsync()).ToList(); + ApplySessionFilter(); } - - @if (prerequisiteReport is not null) + catch (Exception ex) { -
-
- Prerequisites -
- @prerequisiteReport.OkCount ok - @prerequisiteReport.WarningCount warning - @prerequisiteReport.ErrorCount error -
-
-
-
- @foreach (var check in prerequisiteReport.Checks) - { -
-
- @check.Name - @check.Status -
-
@check.Message
-
- @check.Kind - @if (!string.IsNullOrWhiteSpace(check.InstalledVersion)) - { - Installed: @check.InstalledVersion - } - @if (!string.IsNullOrWhiteSpace(check.RequiredVersion)) - { - Required: @check.RequiredVersion - } -
- @if (!string.IsNullOrWhiteSpace(check.SuggestedCommand)) - { - @check.SuggestedCommand - } -
- } -
-
-
+ await AlertService.ShowToastAsync($"Failed to load sessions: {ex.Message}"); } - - @if (capturePlan is not null) + finally { -
-
- Capture plan -
- - @(capturePlan.Validation.IsValid ? "Valid" : "Needs attention") - - - @(capturePlan.CanExecute ? "Executable" : "Preview only") - -
-
-
-
-
-
Target framework
-
@capturePlan.TargetFramework
-
-
-
Output directory
-
@capturePlan.OutputDirectory
-
-
-
Command steps
-
@capturePlan.Commands.Count
-
-
-
Artifacts
-
@capturePlan.ExpectedArtifacts.Count
-
-
- - @if (capturePlan.Validation.Errors.Count > 0) - { -
-
Errors
-
    - @foreach (var error in capturePlan.Validation.Errors) - { -
  • @error
  • - } -
-
- } - - @if (capturePlan.Validation.Warnings.Count > 0) - { -
-
Warnings
-
    - @foreach (var warning in capturePlan.Validation.Warnings) - { -
  • @warning
  • - } -
-
- } - - @if (capturePlan.RuntimeBindings.Count > 0) - { -
-
Runtime bindings
-
    - @foreach (var binding in capturePlan.RuntimeBindings) - { -
  • - @binding.Token — @binding.Description - @if (!string.IsNullOrWhiteSpace(binding.ExampleValue)) - { - (example: @binding.ExampleValue) - } -
  • - } -
-
- } - -
Commands
-
- @foreach (var step in capturePlan.Commands) - { -
-
-
-
@step.DisplayName
-
@step.Description
-
-
- @step.Kind - @if (step.IsLongRunning) - { - Long-running - } - @if (step.RequiresManualStop) - { - Manual stop - } -
-
- @BuildCommandLine(step) - @if (step.DependsOn?.Count > 0 || step.RequiredRuntimeBindings?.Count > 0) - { -
- @if (step.DependsOn?.Count > 0) - { - Depends on: @string.Join(", ", step.DependsOn) - } - @if (step.RequiredRuntimeBindings?.Count > 0) - { - Needs: @string.Join(", ", step.RequiredRuntimeBindings) - } -
- } -
- -
-
- } -
- -
Expected artifacts
-
- @foreach (var artifact in capturePlan.ExpectedArtifacts) - { -
-
@artifact.DisplayName
-
@artifact.RelativePath
-
@artifact.Kind · @artifact.ContentType
-
- } -
-
-
+ isLoadingSessions = false; + StateHasChanged(); } } - @if (currentStep == 4) + private void ApplySessionFilter() { -
-
- Capture -
- @if (pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) - { - @FormatElapsed(pipelineElapsed) - } -
-
-
- @if (pipelineState == ProfilingPipelineState.NotStarted) - { -
- -
Starting capture pipeline...
-
- } - else - { -
- - @GetPipelineStateText() -
- -
- @foreach (var stepStatus in PipelineRunner.Steps) - { -
-
-
- @switch (stepStatus.State) - { - case ProfilingStepState.Running: - - break; - case ProfilingStepState.Completed: - - break; - case ProfilingStepState.Failed: - - break; - case ProfilingStepState.Stopped: - - break; - case ProfilingStepState.Skipped: - - break; - case ProfilingStepState.Cancelled: - - break; - default: - - break; - } -
-
-
@stepStatus.DisplayName
-
- @stepStatus.Kind - @if (stepStatus.IsLongRunning) - { - Long-running - } - @if (stepStatus.Duration.HasValue) - { - @FormatElapsed(stepStatus.Duration.Value) - } - else if (stepStatus.State == ProfilingStepState.Running && stepStatus.StartedAt.HasValue) - { - @FormatElapsed(DateTime.Now - stepStatus.StartedAt.Value) - } -
-
-
- -
-
- @if (stepStatus.ErrorMessage is not null && stepStatus.State == ProfilingStepState.Failed) - { -
- @stepStatus.ErrorMessage -
- } - @if (expandedStepLogs.Contains(stepStatus.StepId)) - { -
- @if (stepStatus.OutputLines.Count == 0) - { -
No output yet
- } - else - { - @foreach (var line in stepStatus.OutputLines.TakeLast(200)) - { -
@line.Text
- } - } -
- } -
- } -
- } -
-
+ if (string.IsNullOrWhiteSpace(sessionSearchText)) + { + filteredSessions = sessions; + } + else + { + filteredSessions = sessions + .Where(s => + s.Name.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || + s.Target.DisplayName.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || + s.Target.Platform.ToString().Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || + (s.Project?.Name?.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + } + } - @if (pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) + private void ToggleSessionExpand(string sessionId) + { + if (expandedSessionId == sessionId) { -
- @if (pipelineState == ProfilingPipelineState.Running) - { -
- - Launching capture pipeline... -
- } - else - { - - } - -
+ expandedSessionId = null; + CloseSpeedscopeViewer(); + CloseGcDumpViewer(); + } + else + { + expandedSessionId = sessionId; + CloseSpeedscopeViewer(); + CloseGcDumpViewer(); } } - @if (currentStep == 5) + private async Task DeleteSessionAsync(ProfilingSessionManifest session) { -
-
- Results -
-
- @if (pipelineResult is null) - { -
- -
No results yet
-
- } - else - { -
-
- -
-
-
- @(pipelineResult.Success ? "Capture completed successfully" : "Capture completed with errors") -
-
- @FormatElapsed(pipelineResult.TotalDuration) - @pipelineResult.ArtifactPaths.Count artifact(s) captured - @if (pipelineResult.MissingArtifacts.Count > 0) - { - @pipelineResult.MissingArtifacts.Count expected artifact(s) missing - } -
-
-
- - @if (pipelineResult.ArtifactPaths.Count > 0) - { -
Captured artifacts
-
- @foreach (var artifactPath in pipelineResult.ArtifactPaths) - { - var path = artifactPath; - var fileName = Path.GetFileName(path); - var extension = GetArtifactExtension(path); - var fileSize = GetFileSize(path); - var isTraceFile = extension is ".nettrace" or ".speedscope.json"; - var isGcDump = extension is ".gcdump"; -
-
- -
-
-
@fileName
-
@path
- @if (fileSize is not null) - { -
@fileSize
- } -
-
- @if (isTraceFile) - { - - } - @if (isGcDump) - { - - } - - -
-
- } -
- } - - @if (pipelineResult.MissingArtifacts.Count > 0) - { -
Missing artifacts
-
- @foreach (var missing in pipelineResult.MissingArtifacts) - { -
-
- -
-
-
@Path.GetFileName(missing)
-
@missing
-
Expected but not found
-
-
- } -
- } - - @* Inline Speedscope Viewer *@ - @if (showSpeedscopeViewer && speedscopeFilePath is not null) - { -
- CPU Trace Viewer - -
-
- @if (speedscopeLoading) - { -
- -
Loading trace viewer...
-
- } - -
- } - - @* Inline GC Dump Viewer *@ - @if (showGcDumpViewer && gcDumpReport is not null) - { -
- GC Dump — Heap Statistics - -
-
- @gcDumpReport.TotalCount.ToString("N0") objects - @FormatBytes(gcDumpReport.TotalSize) - @gcDumpReport.Types.Count types -
-
- -
-
- - - - - - - - - - - @foreach (var entry in GetFilteredGcDumpTypes()) - { - var pct = gcDumpReport.TotalSize > 0 - ? (double)entry.Size / gcDumpReport.TotalSize * 100 - : 0; - - - - - - - } - -
- Type Name - @if (gcDumpSortColumn == "name") { } - - Count - @if (gcDumpSortColumn == "count") { } - - Total Size - @if (gcDumpSortColumn == "size") { } - %
- @entry.TypeName - @entry.Count.ToString("N0")@FormatBytes(entry.Size) -
-
- @pct.ToString("F1")% -
-
-
- } - else if (showGcDumpViewer && gcDumpLoading) - { -
- GC Dump Analysis -
-
- -
Analyzing GC dump...
-
- } - else if (showGcDumpViewer && gcDumpError is not null) - { -
- GC Dump Analysis - -
-
- - @gcDumpError -
- } - } -
-
- -
- -
- } + var confirmed = await AlertService.ShowConfirmAsync( + "Delete Session", + $"Delete \"{session.Name}\" and all its artifacts? This cannot be undone."); + if (!confirmed) return; -
- - @if (currentStep > 0 && currentStep != 4) + try { - + await SessionStorage.DeleteSessionAsync(session.Id); + sessions.Remove(session); + ApplySessionFilter(); + if (expandedSessionId == session.Id) + expandedSessionId = null; + await AlertService.ShowToastAsync("Session deleted"); } -
- @if (currentStep < 4) + catch (Exception ex) { - + await AlertService.ShowToastAsync($"Failed to delete session: {ex.Message}"); } -
- } @* end catalog else *@ -} - -@code { - private ViewMode viewMode = ViewMode.SessionList; - private List sessions = new(); - private List filteredSessions = new(); - private bool isLoadingSessions; - private string sessionSearchText = ""; - private string? expandedSessionId; - private string? activeSessionId; // the session being captured - - private ProfilingCatalog? catalog; - private ConnectedDevicesSnapshot snapshot = ConnectedDevicesSnapshot.Empty; - private List androidEmulators = new(); - private List appleSimulators = new(); - private List targetOptions = new(); - private HashSet selectedCaptureKinds = new(); - private ProfilingPrerequisiteReport? prerequisiteReport; - private ProfilingCapturePlan? capturePlan; - private string? selectedTargetKey; - private string? projectPath; - private string? targetFrameworkOverride; - private string? outputDirectory; - private string? processIdText; - private string configuration = "Release"; - private int diagnosticPort = 9000; - private bool suspendAtStartup = true; - private bool isRefreshingTargets; - private bool isCheckingPrerequisites; - private bool isPlanningCapture; - private int knownAndroidEmulatorCount; - private int knownAppleSimulatorCount; - private ProfilingTargetPlatform selectedPlatform = ProfilingTargetPlatform.Android; - private ProfilingScenarioKind selectedScenario = ProfilingScenarioKind.Launch; - private ProfilingCaptureLaunchMode launchMode = ProfilingCaptureLaunchMode.Launch; - - private int currentStep = 0; - private const int TotalSteps = 6; - private bool showAdvanced = false; - - // Pipeline state - private ProfilingPipelineState pipelineState = ProfilingPipelineState.NotStarted; - private ProfilingPipelineResult? pipelineResult; - private TimeSpan pipelineElapsed; - private System.Threading.Timer? pipelineTimer; - private DateTime pipelineStartTime; - private HashSet expandedStepLogs = new(); - - // Speedscope viewer state - private bool showSpeedscopeViewer; - private bool speedscopeLoading; - private string? speedscopeFilePath; - private IJSObjectReference? speedscopeModule; - - // GC dump viewer state - private bool showGcDumpViewer; - private bool gcDumpLoading; - private string? gcDumpError; - private GcDumpReport? gcDumpReport; - private string gcDumpFilterText = ""; - private string gcDumpSortColumn = "size"; - private bool gcDumpSortAsc = false; - - private static readonly (string Name, string Icon)[] WizardSteps = new[] - { - ("Session Setup", "fa-sliders"), - ("Capture Kinds", "fa-list-check"), - ("Target", "fa-bullseye"), - ("Review & Plan", "fa-clipboard-check"), - ("Capture", "fa-circle-play"), - ("Results", "fa-chart-bar") - }; - - private bool CanProceedToNext => currentStep switch - { - 0 => launchMode == ProfilingCaptureLaunchMode.Attach || !string.IsNullOrWhiteSpace(projectPath), - 1 => selectedCaptureKinds.Count > 0, - 2 => SelectedTarget is not null, - 3 => capturePlan is not null && capturePlan.Validation.IsValid, - _ => false - }; - - private IReadOnlyList TargetOptions => targetOptions; - private TargetOption? SelectedTarget => targetOptions.FirstOrDefault(option => option.Key == selectedTargetKey); - private ProfilingPlatformCapabilities? CurrentPlatformCapabilities => catalog?.Platforms.FirstOrDefault(platform => platform.Platform == selectedPlatform); - private ProfilingScenarioDefinition? CurrentScenarioDefinition => catalog?.Scenarios.FirstOrDefault(scenario => scenario.Kind == selectedScenario); - private IReadOnlyList SupportedCaptureKinds => CurrentPlatformCapabilities?.SupportedCaptureKinds ?? Array.Empty(); - private string? CurrentPlatformNotes => CurrentPlatformCapabilities?.Notes; - - protected override async Task OnInitializedAsync() - { - SetupToolbar(); - ToolbarService.ToolbarItemClicked += OnToolbarItemClicked; - ToolbarService.SearchTextChanged += OnSearchTextChanged; - DeviceMonitor.Changed += OnDeviceMonitorChanged; - - // Load sessions first (fast) - await LoadSessionsAsync(); - - // Load catalog in background (needed for wizard) - catalog = await ProfilingCatalogService.GetCatalogAsync(); - selectedPlatform = catalog.Platforms.FirstOrDefault(platform => platform.Platform == ProfilingTargetPlatform.Android)?.Platform - ?? catalog.Platforms.First().Platform; - selectedScenario = catalog.Scenarios.FirstOrDefault(scenario => scenario.Kind == ProfilingScenarioKind.Launch)?.Kind - ?? catalog.Scenarios.First().Kind; - ResetCaptureKindsToScenarioDefaults(); - - await RefreshTargetsAsync(); } - private void OnToolbarItemClicked(string actionId) + private async Task ExportSessionAsync(ProfilingSessionManifest session) { - if (actionId == "create") - _ = InvokeAsync(StartNewWizard); - else if (actionId == "import") - _ = ImportSessionFromZipAsync(); - else if (actionId == "refresh") + try { - if (viewMode == ViewMode.SessionList) - _ = LoadSessionsAsync(); - else - _ = RefreshTargetsAsync(forceRefresh: true); - } - } + var outputPath = await DialogService.PickSaveFileAsync( + "Export Profiling Session", + $"{session.Id}", + ".zip"); + if (string.IsNullOrEmpty(outputPath)) return; - private void OnSearchTextChanged(string text) - { - sessionSearchText = text; - ApplySessionFilter(); - InvokeAsync(StateHasChanged); - } + // PickSaveFileAsync creates an empty file; delete so ZipFile can create + if (File.Exists(outputPath)) File.Delete(outputPath); - private void OnDeviceMonitorChanged(ConnectedDevicesSnapshot updatedSnapshot) - { - snapshot = updatedSnapshot; - BuildTargetOptions(); - InvokeAsync(StateHasChanged); + await SessionStorage.ExportSessionAsync(session.Id, outputPath); + await AlertService.ShowToastAsync("Session exported"); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Export failed: {ex.Message}"); + } } - private async Task RefreshTargetsAsync(bool forceRefresh = false) + private async Task ImportSessionFromZipAsync() { - isRefreshingTargets = true; - StateHasChanged(); - try { - if (forceRefresh) + var filePath = await DialogService.PickOpenFileAsync( + "Import Profiling Session", + new[] { ".zip" }); + if (string.IsNullOrEmpty(filePath)) return; + + var imported = await SessionStorage.ImportSessionAsync(filePath); + if (imported is null) { - await Mediator.FlushStores("android:emulators"); - await Mediator.FlushStores("apple:simulators"); + await AlertService.ShowToastAsync("Invalid session archive — missing session.json"); + return; } - var emuTask = Mediator.Request(new GetEmulatorsRequest()); - var simTask = Mediator.Request(new GetSimulatorsRequest()); - - await Task.WhenAll(emuTask, simTask); - - var (_, emuResult) = await emuTask; - androidEmulators = emuResult?.ToList() ?? new(); - knownAndroidEmulatorCount = androidEmulators.Count; - - var (_, simResult) = await simTask; - appleSimulators = simResult?.Where(simulator => simulator.IsAvailable).ToList() ?? new(); - knownAppleSimulatorCount = appleSimulators.Count; - - snapshot = DeviceMonitor.Current; - BuildTargetOptions(); + await LoadSessionsAsync(); + await AlertService.ShowToastAsync($"Session \"{imported.Name}\" imported"); } catch (Exception ex) { - await AlertService.ShowToastAsync($"Error refreshing profiling targets: {ex.Message}"); - } - finally - { - isRefreshingTargets = false; - StateHasChanged(); + await AlertService.ShowToastAsync($"Import failed: {ex.Message}"); } } - private Task OnPlatformChangedAsync() - { - ResetCaptureKindsToScenarioDefaults(); - BuildTargetOptions(); - prerequisiteReport = null; - capturePlan = null; - return Task.CompletedTask; - } - - private Task OnScenarioChangedAsync() - { - ResetCaptureKindsToScenarioDefaults(); - prerequisiteReport = null; - capturePlan = null; - return Task.CompletedTask; - } - - private Task OnLaunchModeChangedAsync() + private void RevealSessionFolder(ProfilingSessionManifest session) { - capturePlan = null; - return Task.CompletedTask; + if (session.DirectoryPath is null) return; + RevealInFinder(session.DirectoryPath); } - private void ResetCaptureKindsToScenarioDefaults() + private static string GetPlatformIcon(ProfilingTargetPlatform platform) => platform switch { - var defaults = CurrentScenarioDefinition?.DefaultCaptureKinds - .Where(kind => SupportedCaptureKinds.Contains(kind)) - .ToArray(); + ProfilingTargetPlatform.Android => "fa-brands fa-android", + ProfilingTargetPlatform.iOS => "fa-brands fa-apple", + ProfilingTargetPlatform.MacCatalyst => "fa-laptop", + ProfilingTargetPlatform.MacOS => "fa-laptop", + ProfilingTargetPlatform.Windows => "fa-brands fa-windows", + _ => "fa-desktop" + }; - selectedCaptureKinds = defaults is { Length: > 0 } - ? defaults.ToHashSet() - : SupportedCaptureKinds.ToHashSet(); - } + private static string FormatElapsed(TimeSpan ts) => + ts.TotalMinutes >= 1 ? $"{(int)ts.TotalMinutes}m {ts.Seconds}s" : $"{ts.Seconds}s"; - private void SelectTarget(string key) + private static string? GetFileSize(string path) { - selectedTargetKey = key; - capturePlan = null; + try + { + if (!File.Exists(path)) return null; + var bytes = new FileInfo(path).Length; + if (bytes >= 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):F1} MB"; + if (bytes >= 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes} B"; + } + catch { return null; } } - private void OnCaptureKindChanged(ProfilingCaptureKind kind, ChangeEventArgs args) + private static string GetArtifactExtension(string path) { - var isSelected = args.Value as bool? == true; - if (isSelected) - selectedCaptureKinds.Add(kind); - else if (selectedCaptureKinds.Count > 1) - selectedCaptureKinds.Remove(kind); - - capturePlan = null; + if (path.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + return ".speedscope.json"; + return Path.GetExtension(path).ToLowerInvariant(); } - private async Task BrowseProjectAsync() + private static string GetArtifactIcon(string extension) => extension switch { - var selectedPath = await DialogService.PickOpenFileAsync("Select project", new[] { ".csproj" }); - if (!string.IsNullOrWhiteSpace(selectedPath)) - { - projectPath = selectedPath; - capturePlan = null; - StateHasChanged(); - } - } + ".nettrace" => "fa-wave-square", + ".speedscope.json" => "fa-fire", + ".gcdump" => "fa-memory", + ".txt" or ".log" => "fa-file-lines", + ".json" => "fa-code", + _ => "fa-file" + }; - private async Task CheckPrerequisitesAsync() + private static string FormatBytes(long bytes) { - isCheckingPrerequisites = true; - StateHasChanged(); - - try - { - var captureKinds = GetSelectedCaptureKinds(); - var (_, report) = await Mediator.Request(new GetProfilingPrerequisitesRequest(selectedPlatform, captureKinds)); - prerequisiteReport = report; - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Unable to load profiling prerequisites: {ex.Message}"); - } - finally - { - isCheckingPrerequisites = false; - StateHasChanged(); - } + if (bytes >= 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):F1} MB"; + if (bytes >= 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes:N0} B"; } - private async Task GeneratePlanAsync() + // Speedscope viewer + private async Task ViewInSpeedscope(string path) { - if (SelectedTarget is null) - { - await AlertService.ShowToastAsync("Select a profiling target first."); - return; - } - - isPlanningCapture = true; - StateHasChanged(); - try { - var definition = ProfilingCatalogService.CreateSessionDefinition( - SelectedTarget.ToProfilingTarget(), - selectedScenario, - captureKinds: GetSelectedCaptureKinds()); + string? speedscopePath = path; + if (path.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) + { + speedscopePath = await ArtifactConverter.ConvertToSpeedscopeAsync(path); + if (speedscopePath is null) + { + await AlertService.ShowToastAsync("Failed to convert trace to speedscope format."); + return; + } + } - var (_, plan) = await Mediator.Request(new PlanProfilingCaptureRequest(definition, BuildPlanOptions(SelectedTarget))); - capturePlan = plan; + speedscopeFilePath = speedscopePath; + showSpeedscopeViewer = true; + speedscopeLoading = true; + StateHasChanged(); } catch (Exception ex) { - await AlertService.ShowToastAsync($"Unable to generate profiling plan: {ex.Message}"); - } - finally - { - isPlanningCapture = false; - StateHasChanged(); + await AlertService.ShowToastAsync($"Failed to open trace viewer: {ex.Message}"); } } - private ProfilingCapturePlanOptions BuildPlanOptions(TargetOption selectedTarget) + private async Task OnSpeedscopeLoaded() { - var additionalBuildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (selectedTarget.Platform == ProfilingTargetPlatform.Android && - selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Emulator) - { - additionalBuildProperties["AdbTarget"] = $"-s {selectedTarget.Identifier}"; - } + if (speedscopeFilePath is null) return; - if (selectedTarget.Platform == ProfilingTargetPlatform.iOS && - selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Simulator) + try { - additionalBuildProperties["_DeviceName"] = $":v2:udid={selectedTarget.Identifier}"; - } - - int? processId = null; - if (int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) - processId = parsedProcessId; - - return new ProfilingCapturePlanOptions( - ProjectPath: string.IsNullOrWhiteSpace(projectPath) ? null : projectPath, - Configuration: configuration, - TargetFramework: string.IsNullOrWhiteSpace(targetFrameworkOverride) ? null : targetFrameworkOverride, - OutputDirectory: string.IsNullOrWhiteSpace(outputDirectory) ? null : outputDirectory, - LaunchMode: launchMode, - DiagnosticPort: diagnosticPort, - SuspendAtStartup: suspendAtStartup, - ProcessId: processId, - AdditionalBuildProperties: additionalBuildProperties.Count == 0 ? null : additionalBuildProperties); - } - - private void BuildTargetOptions() - { - var options = new List(); - - switch (selectedPlatform) - { - case ProfilingTargetPlatform.Android: - options.AddRange(snapshot.AndroidDevices.Select(device => new TargetOption( - Key: $"android-device:{device.Serial}", - Platform: ProfilingTargetPlatform.Android, - Kind: ProfilingTargetKind.PhysicalDevice, - Identifier: device.Serial, - DisplayName: device.Model ?? device.Serial, - Subtitle: "Connected Android device", - KindLabel: "Device", - IsAvailable: true, - ExtraHint: device.State))); - - options.AddRange(snapshot.AndroidEmulators.Select(device => new TargetOption( - Key: $"android-emulator:{device.Serial}", - Platform: ProfilingTargetPlatform.Android, - Kind: ProfilingTargetKind.Emulator, - Identifier: device.Serial, - DisplayName: device.Model ?? device.Serial, - Subtitle: "Running Android emulator", - KindLabel: "Emulator", - IsAvailable: true, - ExtraHint: device.State))); - break; - - case ProfilingTargetPlatform.iOS: - options.AddRange(snapshot.ApplePhysicalDevices.Select(device => new TargetOption( - Key: $"ios-device:{device.Identifier}", - Platform: ProfilingTargetPlatform.iOS, - Kind: ProfilingTargetKind.PhysicalDevice, - Identifier: device.Identifier, - DisplayName: device.Name, - Subtitle: $"{device.ModelName} · iOS {device.OsVersion}", - KindLabel: "Device", - IsAvailable: device.IsAvailable, - ExtraHint: device.Interface))); - - options.AddRange(appleSimulators.Select(simulator => new TargetOption( - Key: $"ios-simulator:{simulator.Udid}", - Platform: ProfilingTargetPlatform.iOS, - Kind: ProfilingTargetKind.Simulator, - Identifier: simulator.Udid, - DisplayName: simulator.Name, - Subtitle: $"{simulator.Runtime ?? "Unknown runtime"} · {simulator.ProductFamily ?? "Simulator"}", - KindLabel: "Simulator", - IsAvailable: string.Equals(simulator.State, "Booted", StringComparison.OrdinalIgnoreCase), - ExtraHint: simulator.State))); - break; - - case ProfilingTargetPlatform.MacCatalyst: - if (Platform.IsMacCatalyst || Platform.IsMacOS) - { - options.Add(new TargetOption( - Key: "desktop:maccatalyst", - Platform: ProfilingTargetPlatform.MacCatalyst, - Kind: ProfilingTargetKind.Desktop, - Identifier: "local-maccatalyst", - DisplayName: "This Mac", - Subtitle: "Local Mac Catalyst target", - KindLabel: "Desktop", - IsAvailable: true)); - } - break; - - case ProfilingTargetPlatform.MacOS: - if (Platform.IsMacOS) - { - options.Add(new TargetOption( - Key: "desktop:macos", - Platform: ProfilingTargetPlatform.MacOS, - Kind: ProfilingTargetKind.Desktop, - Identifier: "local-macos", - DisplayName: "This Mac", - Subtitle: "Local macOS target", - KindLabel: "Desktop", - IsAvailable: true)); - } - break; - - case ProfilingTargetPlatform.Windows: - if (Platform.IsWindows) - { - options.Add(new TargetOption( - Key: "desktop:windows", - Platform: ProfilingTargetPlatform.Windows, - Kind: ProfilingTargetKind.Desktop, - Identifier: "local-windows", - DisplayName: "This PC", - Subtitle: "Local Windows target", - KindLabel: "Desktop", - IsAvailable: true)); - } - break; - } - - targetOptions = options; - if (selectedTargetKey is null || targetOptions.All(option => option.Key != selectedTargetKey)) - selectedTargetKey = targetOptions.FirstOrDefault()?.Key; - } - - private IReadOnlyList GetSelectedCaptureKinds() - => selectedCaptureKinds.Count > 0 - ? selectedCaptureKinds.OrderBy(kind => kind).ToArray() - : SupportedCaptureKinds; - - private static string GetPlatformDisplayName(ProfilingTargetPlatform platform) => platform switch - { - ProfilingTargetPlatform.iOS => "iOS", - ProfilingTargetPlatform.MacCatalyst => "Mac Catalyst", - ProfilingTargetPlatform.MacOS => "macOS", - _ => platform.ToString() - }; - - private static string GetCaptureKindDisplayName(ProfilingCaptureKind captureKind) => captureKind switch - { - ProfilingCaptureKind.Cpu => "CPU trace", - ProfilingCaptureKind.Memory => "Memory / GC dump", - ProfilingCaptureKind.Network => "Network", - ProfilingCaptureKind.Rendering => "Rendering", - ProfilingCaptureKind.Energy => "Energy", - ProfilingCaptureKind.SystemTrace => "System trace", - ProfilingCaptureKind.Logs => "Logs", - ProfilingCaptureKind.Startup => "Startup", - _ => captureKind.ToString() - }; - - private static string GetDependencyCss(DependencyStatusType status) => status switch - { - DependencyStatusType.Ok => "ok", - DependencyStatusType.Info => "ok", - DependencyStatusType.Warning => "warning", - DependencyStatusType.Error => "error", - _ => "neutral" - }; - - private async Task CopyCommandAsync(ProfilingCommandStep step) - { - await DialogService.CopyToClipboardAsync(BuildCommandLine(step)); - await AlertService.ShowToastAsync("Copied command to clipboard."); - } - - private string BuildCommandLine(ProfilingCommandStep step) - { - var resolved = ResolveCommandStep(step); - return resolved?.CommandLine ?? step.CommandLine; - } - - private ProcessRequest? ResolveCommandStep(ProfilingCommandStep step) - { - if (step.RequiredRuntimeBindings is not null) - { - foreach (var binding in step.RequiredRuntimeBindings) - { - if (!TryResolveRuntimeBinding(binding, out _)) - return null; - } - } - - var resolvedArguments = step.Arguments.Select(ResolveRuntimeTokens).ToArray(); - var resolvedEnvironment = step.Environment?.ToDictionary( - kvp => kvp.Key, - kvp => ResolveRuntimeTokens(kvp.Value), - StringComparer.OrdinalIgnoreCase); - - return new ProcessRequest( - step.Command, - resolvedArguments, - step.WorkingDirectory, - Environment: resolvedEnvironment, - Title: step.DisplayName, - Description: step.Description); - } - - private string ResolveRuntimeTokens(string value) - { - foreach (var binding in capturePlan?.RuntimeBindings ?? Array.Empty()) - { - if (TryResolveRuntimeBinding(binding.Token, out var resolvedValue)) - value = value.Replace(binding.Token, resolvedValue, StringComparison.Ordinal); - } - - return value; - } - - private bool TryResolveRuntimeBinding(string token, out string value) - { - if (token == "{{PROCESS_ID}}" && int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) - { - value = parsedProcessId.ToString(); - return true; - } - - value = string.Empty; - return false; - } - - - private async Task NextStep() - { - if (!CanProceedToNext) return; - - if (currentStep == 2) - { - currentStep = 3; - await CheckPrerequisitesAsync(); - if (SelectedTarget is not null) - await GeneratePlanAsync(); - } - else if (currentStep == 3) - { - // Start Capture — advance to step 4 and run the pipeline - currentStep = 4; - await StartPipelineAsync(); - } - else - { - currentStep = Math.Min(currentStep + 1, TotalSteps - 1); - } - } - - private void PreviousStep() - { - currentStep = Math.Max(currentStep - 1, 0); - } - - private void GoToStep(int step) - { - // Don't allow jumping forward past current, or back into step 4 (capture) - if (step < currentStep && step != 4) - currentStep = step; - } - - private async Task StartPipelineAsync() - { - if (capturePlan is null) return; - - // Generate session ID and set output directory to managed storage - var projectName = projectPath is not null - ? Path.GetFileNameWithoutExtension(projectPath) - : null; - activeSessionId = SessionStorage.GenerateSessionId(projectName); - var sessionDir = SessionStorage.GetSessionDirectoryPath(activeSessionId); - - // Update the capture plan to use the session directory as output - capturePlan = capturePlan with { OutputDirectory = sessionDir }; - - pipelineState = ProfilingPipelineState.NotStarted; - pipelineResult = null; - expandedStepLogs.Clear(); - pipelineStartTime = DateTime.Now; - StateHasChanged(); - - // Subscribe to pipeline events - PipelineRunner.PipelineStateChanged += OnPipelineStateChanged; - PipelineRunner.StepStateChanged += OnStepStateChanged; - PipelineRunner.StepOutputReceived += OnStepOutputReceived; - - // Start elapsed timer (updates every second) - pipelineTimer = new System.Threading.Timer(_ => - { - pipelineElapsed = DateTime.Now - pipelineStartTime; - InvokeAsync(StateHasChanged); - }, null, TimeSpan.Zero, TimeSpan.FromSeconds(1)); - - try - { - pipelineResult = await PipelineRunner.RunAsync(capturePlan); - pipelineState = pipelineResult.FinalState; - - // Save session manifest - await SaveCurrentSessionAsync(); - - // Auto-advance to results on completion - if (pipelineResult.Success || pipelineState == ProfilingPipelineState.Completed) - { - currentStep = 5; - } - } - catch (Exception ex) - { - pipelineState = ProfilingPipelineState.Failed; - await AlertService.ShowToastAsync($"Pipeline failed: {ex.Message}"); - } - finally - { - pipelineTimer?.Dispose(); - pipelineTimer = null; - PipelineRunner.PipelineStateChanged -= OnPipelineStateChanged; - PipelineRunner.StepStateChanged -= OnStepStateChanged; - PipelineRunner.StepOutputReceived -= OnStepOutputReceived; - StateHasChanged(); - } - } - - private void OnPipelineStateChanged(object? sender, ProfilingPipelineStateChangedEventArgs e) - { - pipelineState = e.NewState; - InvokeAsync(StateHasChanged); - } - - private void OnStepStateChanged(object? sender, ProfilingStepStateChangedEventArgs e) - { - // Auto-expand log for running steps - if (e.NewState == ProfilingStepState.Running) - { - expandedStepLogs.Add(e.StepId); - // Start tracking auto-scroll after render - InvokeAsync(async () => - { - StateHasChanged(); - await Task.Yield(); - try { await JS.InvokeVoidAsync("logScrollInterop.track", $"step-log-{e.StepId}"); } catch { } - }); - return; - } - InvokeAsync(StateHasChanged); - } - - private void OnStepOutputReceived(object? sender, ProfilingStepOutputEventArgs e) - { - InvokeAsync(async () => - { - StateHasChanged(); - await Task.Yield(); - try { await JS.InvokeVoidAsync("logScrollInterop.scrollToBottom", $"step-log-{e.StepId}"); } catch { } - }); - } - - private async Task HandleStopCapture() - { - await PipelineRunner.StopCaptureAsync(); - } - - private void HandleCancelPipeline() - { - PipelineRunner.Cancel(); - pipelineState = ProfilingPipelineState.Cancelled; - currentStep = 3; // Go back to review - StateHasChanged(); - } - - private async void ToggleStepLog(string stepId) - { - if (!expandedStepLogs.Remove(stepId)) - { - expandedStepLogs.Add(stepId); - StateHasChanged(); - await Task.Yield(); - try - { - await JS.InvokeVoidAsync("logScrollInterop.track", $"step-log-{stepId}"); - await JS.InvokeVoidAsync("logScrollInterop.scrollToBottom", $"step-log-{stepId}"); - } - catch { } - } - else - { - try { await JS.InvokeVoidAsync("logScrollInterop.untrack", $"step-log-{stepId}"); } catch { } - } - } - - private string GetPipelineStateIcon() => pipelineState switch - { - ProfilingPipelineState.Running => "fa-spinner fa-spin", - ProfilingPipelineState.WaitingForStop => "fa-circle-dot fa-beat-fade", - ProfilingPipelineState.Completing => "fa-spinner fa-spin", - ProfilingPipelineState.Completed => "fa-check-circle", - ProfilingPipelineState.Failed => "fa-times-circle", - ProfilingPipelineState.Cancelled => "fa-ban", - _ => "fa-circle" - }; - - private string GetPipelineStateText() => pipelineState switch - { - ProfilingPipelineState.Running => "Launching capture pipeline...", - ProfilingPipelineState.WaitingForStop => "Recording — click Stop Capture when ready", - ProfilingPipelineState.Completing => "Stopping capture and collecting artifacts...", - ProfilingPipelineState.Completed => "Capture completed successfully", - ProfilingPipelineState.Failed => "Capture failed — check step details below", - ProfilingPipelineState.Cancelled => "Capture cancelled", - _ => "Initializing..." - }; - - private static string FormatElapsed(TimeSpan ts) => - ts.TotalMinutes >= 1 ? $"{(int)ts.TotalMinutes}m {ts.Seconds}s" : $"{ts.Seconds}s"; - - // Results helpers - private void StartNewSession() - { - StartNewWizard(); - } - - private void StartNewWizard() - { - currentStep = 0; - capturePlan = null; - prerequisiteReport = null; - pipelineResult = null; - pipelineState = ProfilingPipelineState.NotStarted; - expandedStepLogs.Clear(); - activeSessionId = null; - CloseSpeedscopeViewer(); - CloseGcDumpViewer(); - viewMode = ViewMode.Wizard; - SetupToolbar(); - } - - private void BackToSessionList() - { - if (pipelineState == ProfilingPipelineState.Running) - return; // Don't navigate away during active capture - - viewMode = ViewMode.SessionList; - CloseSpeedscopeViewer(); - CloseGcDumpViewer(); - SetupToolbar(); - _ = LoadSessionsAsync(); - } - - private void SetupToolbar() - { - if (viewMode == ViewMode.SessionList) - { - ToolbarService.ClearItems(); - ToolbarService.SetItems( - new ToolbarAction("create", "New Session", "plus"), - new ToolbarAction("import", "Import", "square.and.arrow.down"), - new ToolbarAction("refresh", "Refresh", "arrow.clockwise") - ); - ToolbarService.SetSearch("Search sessions..."); - } - else - { - ToolbarService.ClearItems(); - ToolbarService.SetItems( - new ToolbarAction("refresh", "Refresh", "arrow.clockwise") - ); - } - } - - private async Task LoadSessionsAsync() - { - isLoadingSessions = true; - StateHasChanged(); - - try - { - sessions = (await SessionStorage.GetSessionsAsync()).ToList(); - ApplySessionFilter(); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Failed to load sessions: {ex.Message}"); - } - finally - { - isLoadingSessions = false; - StateHasChanged(); - } - } - - private void ApplySessionFilter() - { - if (string.IsNullOrWhiteSpace(sessionSearchText)) - { - filteredSessions = sessions; - } - else - { - filteredSessions = sessions - .Where(s => - s.Name.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || - s.Target.DisplayName.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || - s.Target.Platform.ToString().Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || - (s.Project?.Name?.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false)) - .ToList(); - } - } - - private void ToggleSessionExpand(string sessionId) - { - if (expandedSessionId == sessionId) - { - expandedSessionId = null; - CloseSpeedscopeViewer(); - CloseGcDumpViewer(); - } - else - { - expandedSessionId = sessionId; - CloseSpeedscopeViewer(); - CloseGcDumpViewer(); - } - } - - private async Task DeleteSessionAsync(ProfilingSessionManifest session) - { - var confirmed = await AlertService.ShowConfirmAsync( - "Delete Session", - $"Delete \"{session.Name}\" and all its artifacts? This cannot be undone."); - if (!confirmed) return; - - try - { - await SessionStorage.DeleteSessionAsync(session.Id); - sessions.Remove(session); - ApplySessionFilter(); - if (expandedSessionId == session.Id) - expandedSessionId = null; - await AlertService.ShowToastAsync("Session deleted"); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Failed to delete session: {ex.Message}"); - } - } - - private async Task ExportSessionAsync(ProfilingSessionManifest session) - { - try - { - var outputPath = await DialogService.PickSaveFileAsync( - "Export Profiling Session", - $"{session.Id}", - ".zip"); - if (string.IsNullOrEmpty(outputPath)) return; - - // PickSaveFileAsync creates an empty file; delete so ZipFile can create - if (File.Exists(outputPath)) File.Delete(outputPath); - - await SessionStorage.ExportSessionAsync(session.Id, outputPath); - await AlertService.ShowToastAsync("Session exported"); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Export failed: {ex.Message}"); - } - } - - private async Task ImportSessionFromZipAsync() - { - try - { - var filePath = await DialogService.PickOpenFileAsync( - "Import Profiling Session", - new[] { ".zip" }); - if (string.IsNullOrEmpty(filePath)) return; - - var imported = await SessionStorage.ImportSessionAsync(filePath); - if (imported is null) - { - await AlertService.ShowToastAsync("Invalid session archive — missing session.json"); - return; - } - - await LoadSessionsAsync(); - await AlertService.ShowToastAsync($"Session \"{imported.Name}\" imported"); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Import failed: {ex.Message}"); - } - } - - private void RevealSessionFolder(ProfilingSessionManifest session) - { - if (session.DirectoryPath is null) return; - RevealInFinder(session.DirectoryPath); - } - - private async Task SaveCurrentSessionAsync() - { - if (pipelineResult is null || capturePlan is null) return; - - try - { - var projectName = projectPath is not null - ? Path.GetFileNameWithoutExtension(projectPath) - : null; - var sessionId = activeSessionId ?? SessionStorage.GenerateSessionId(projectName); - activeSessionId = sessionId; - - var target = SelectedTarget; - var manifest = new ProfilingSessionManifest - { - Id = sessionId, - Name = $"{projectName ?? "Session"} — {selectedPlatform} {target?.DisplayName ?? "Unknown"}", - Status = pipelineResult.Success ? ProfilingSessionStatus.Completed - : (pipelineState == ProfilingPipelineState.Cancelled ? ProfilingSessionStatus.Cancelled - : ProfilingSessionStatus.Failed), - CreatedAt = pipelineStartTime, - CompletedAt = DateTimeOffset.UtcNow, - Target = new ProfilingSessionTarget - { - Platform = selectedPlatform, - Kind = target?.Kind ?? ProfilingTargetKind.Desktop, - Identifier = target?.Identifier ?? "", - DisplayName = target?.DisplayName ?? selectedPlatform.ToString() - }, - Project = projectPath is not null ? new ProfilingSessionProject - { - Path = projectPath, - Name = projectName!, - Configuration = configuration, - TargetFramework = targetFrameworkOverride - } : null, - CaptureKinds = selectedCaptureKinds.ToList(), - Options = new ProfilingSessionOptions - { - LaunchMode = launchMode, - DiagnosticPort = diagnosticPort, - SuspendAtStartup = suspendAtStartup, - ProcessId = int.TryParse(processIdText, out var pid) ? pid : null, - Scenario = selectedScenario - }, - Pipeline = new ProfilingSessionPipelineSummary - { - Success = pipelineResult.Success, - Duration = pipelineResult.TotalDuration, - Steps = pipelineResult.StepResults.Select(step => new ProfilingSessionStepSummary - { - Id = step.StepId, - DisplayName = step.DisplayName, - State = step.State.ToString(), - CommandLine = capturePlan.Commands.FirstOrDefault(c => c.Id == step.StepId)?.CommandLine - }).ToList() - }, - Artifacts = new List() - }; - - // Add discovered artifacts - var sessionDir = SessionStorage.GetSessionDirectoryPath(sessionId); - foreach (var artifactPath in pipelineResult.ArtifactPaths) - { - var fileName = Path.GetFileName(artifactPath); - var extension = GetArtifactExtension(fileName); - var kind = extension switch - { - ".nettrace" => ProfilingArtifactKind.Trace, - ".speedscope.json" => ProfilingArtifactKind.Export, - ".gcdump" => ProfilingArtifactKind.GcDump, - ".log" => ProfilingArtifactKind.Log, - _ => ProfilingArtifactKind.Other - }; - - long? size = null; - try { if (File.Exists(artifactPath)) size = new FileInfo(artifactPath).Length; } catch { } - - manifest.Artifacts.Add(new ProfilingSessionArtifact - { - FileName = fileName, - Kind = kind, - SizeBytes = size - }); - } - - // Add log files from the session directory - if (Directory.Exists(sessionDir)) - { - foreach (var logFile in Directory.GetFiles(sessionDir, "*.log")) - { - var logName = Path.GetFileName(logFile); - if (manifest.Artifacts.Any(a => a.FileName == logName)) continue; - long? size = null; - try { size = new FileInfo(logFile).Length; } catch { } - manifest.Artifacts.Add(new ProfilingSessionArtifact - { - FileName = logName, - Kind = ProfilingArtifactKind.Log, - SizeBytes = size - }); - } - } - - await SessionStorage.SaveSessionAsync(manifest); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Failed to save session: {ex.Message}"); - } - } - - private static string GetPlatformIcon(ProfilingTargetPlatform platform) => platform switch - { - ProfilingTargetPlatform.Android => "fa-brands fa-android", - ProfilingTargetPlatform.iOS => "fa-brands fa-apple", - ProfilingTargetPlatform.MacCatalyst => "fa-laptop", - ProfilingTargetPlatform.MacOS => "fa-laptop", - ProfilingTargetPlatform.Windows => "fa-brands fa-windows", - _ => "fa-desktop" - }; - - private enum ViewMode { SessionList, Wizard } - - private static string? GetFileSize(string path) - { - try - { - if (!File.Exists(path)) return null; - var bytes = new FileInfo(path).Length; - if (bytes >= 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):F1} MB"; - if (bytes >= 1024) return $"{bytes / 1024.0:F1} KB"; - return $"{bytes} B"; - } - catch { return null; } - } - - private static string GetArtifactExtension(string path) - { - if (path.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) - return ".speedscope.json"; - return Path.GetExtension(path).ToLowerInvariant(); - } - - private static string GetArtifactIcon(string extension) => extension switch - { - ".nettrace" => "fa-wave-square", - ".speedscope.json" => "fa-fire", - ".gcdump" => "fa-memory", - ".txt" or ".log" => "fa-file-lines", - ".json" => "fa-code", - _ => "fa-file" - }; - - private static string FormatBytes(long bytes) - { - if (bytes >= 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):F1} MB"; - if (bytes >= 1024) return $"{bytes / 1024.0:F1} KB"; - return $"{bytes:N0} B"; - } - - // Speedscope viewer - private async Task ViewInSpeedscope(string path) - { - try - { - // If it's a .nettrace file, convert to speedscope first - string? speedscopePath = path; - if (path.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) - { - speedscopePath = await ArtifactConverter.ConvertToSpeedscopeAsync(path); - if (speedscopePath is null) - { - await AlertService.ShowToastAsync("Failed to convert trace to speedscope format."); - return; - } - } - - speedscopeFilePath = speedscopePath; - showSpeedscopeViewer = true; - speedscopeLoading = true; - StateHasChanged(); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Failed to open trace viewer: {ex.Message}"); - } - } - - private async Task OnSpeedscopeLoaded() - { - if (speedscopeFilePath is null) return; - - try - { - // Read the file and inject it into speedscope via JS interop - var fileBytes = await File.ReadAllBytesAsync(speedscopeFilePath); - var base64 = Convert.ToBase64String(fileBytes); - var fileName = Path.GetFileName(speedscopeFilePath); - - speedscopeModule ??= await JS.InvokeAsync( - "import", "./js/speedscopeInterop.js"); - - await speedscopeModule.InvokeVoidAsync( - "openSpeedscopeWithFile", "speedscope-frame", speedscopeFilePath, base64, fileName); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Failed to load trace into viewer: {ex.Message}"); - } - finally - { - speedscopeLoading = false; - StateHasChanged(); - } - } - - private void CloseSpeedscopeViewer() - { - showSpeedscopeViewer = false; - speedscopeFilePath = null; - speedscopeLoading = false; - } - - // GC dump viewer - private async Task ViewGcDump(string path) - { - showGcDumpViewer = true; - gcDumpLoading = true; - gcDumpError = null; - gcDumpReport = null; - gcDumpFilterText = ""; - gcDumpSortColumn = "size"; - gcDumpSortAsc = false; - StateHasChanged(); - - try - { - var report = await GcDumpReportService.GetReportAsync(path); - if (report is null) - { - gcDumpError = "Failed to parse GC dump. Make sure dotnet-gcdump is installed."; - } - else - { - gcDumpReport = report; - } - } - catch (Exception ex) - { - gcDumpError = $"Error analyzing GC dump: {ex.Message}"; - } - finally - { - gcDumpLoading = false; - StateHasChanged(); - } - } - - private void CloseGcDumpViewer() - { - showGcDumpViewer = false; - gcDumpReport = null; - gcDumpError = null; - } - - private IEnumerable GetFilteredGcDumpTypes() - { - if (gcDumpReport is null) return Enumerable.Empty(); - - var types = gcDumpReport.Types.AsEnumerable(); - - if (!string.IsNullOrWhiteSpace(gcDumpFilterText)) - { - types = types.Where(t => t.TypeName.Contains(gcDumpFilterText, StringComparison.OrdinalIgnoreCase)); - } - - types = gcDumpSortColumn switch - { - "name" => gcDumpSortAsc ? types.OrderBy(t => t.TypeName) : types.OrderByDescending(t => t.TypeName), - "count" => gcDumpSortAsc ? types.OrderBy(t => t.Count) : types.OrderByDescending(t => t.Count), - "size" => gcDumpSortAsc ? types.OrderBy(t => t.Size) : types.OrderByDescending(t => t.Size), - _ => types - }; - - return types.Take(200); // Limit to 200 rows for performance - } - - private void SortGcDump(string column) - { - if (gcDumpSortColumn == column) - gcDumpSortAsc = !gcDumpSortAsc; - else - { - gcDumpSortColumn = column; - gcDumpSortAsc = column == "name"; // Name sorts ascending by default, others descending - } - } - - private void RevealInFinder(string path) - { - try - { - var dir = Path.GetDirectoryName(path) ?? path; - if (Platform.IsMacCatalyst || Platform.IsMacOS) - { - System.Diagnostics.Process.Start("open", $"-R \"{path}\""); - } - else - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(dir) { UseShellExecute = true }); - } - } - catch { } - } - - private async Task CopyPathAsync(string path) - { - await DialogService.CopyToClipboardAsync(path); - await AlertService.ShowToastAsync("Path copied to clipboard"); - } - - public void Dispose() - { - ToolbarService.ToolbarItemClicked -= OnToolbarItemClicked; - ToolbarService.SearchTextChanged -= OnSearchTextChanged; - ToolbarService.ClearItems(); - DeviceMonitor.Changed -= OnDeviceMonitorChanged; - pipelineTimer?.Dispose(); - if (speedscopeModule is not null) - { - _ = speedscopeModule.DisposeAsync(); - } - } - - private sealed record TargetOption( - string Key, - ProfilingTargetPlatform Platform, - ProfilingTargetKind Kind, - string Identifier, - string DisplayName, - string Subtitle, - string KindLabel, - bool IsAvailable, - string? ExtraHint = null) - { - public ProfilingTarget ToProfilingTarget() => new( - Platform, - Kind, - Identifier, - DisplayName); - } -} - - - From 3b4f558cb5fc3748d5578771eab2e660857d50c2 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 12:25:49 -0400 Subject: [PATCH 21/67] Fix stray closing brace rendered on profiling session list page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa/Pages/Profiling.razor | 1 - 1 file changed, 1 deletion(-) diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index 65715cad..6d93e2f7 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -292,7 +292,6 @@ } } -} @code { private List sessions = new(); From 3d5bb4cbf22317e847f2c59b36b18e34fe355d5f Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 12:45:23 -0400 Subject: [PATCH 22/67] Fix 10 profiling UI polish issues - Fix GC dump timing: add build-and-run dependency so gcdump waits for app - Fix nettrace viewer: handle conversion failure with inline error + retry - Fix artifact card layout: buttons wrap to their own row - Reduce wizard modal padding for compact layout - Add squircle border-radius (14px) on all cards/panels - Fix artifact path overflow: show filename only, full path in tooltip - Make plan step commands collapsible (collapsed by default) - Disable Next button during prerequisites check and plan generation - Simplify prerequisites: green banner when all pass, expandable details - Fix suspend at startup: default to false, add descriptive help text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProfilingCaptureOrchestrationService.cs | 2 + .../Modals/ProfilingCaptureWizardModal.razor | 162 +++++++++++++----- src/MauiSherpa/Pages/Profiling.razor | 106 ++++++++++-- 3 files changed, 213 insertions(+), 57 deletions(-) diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index a7a31d79..b0c1c8db 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -759,6 +759,8 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C arguments.Add(gcdumpArtifactPath); var dependsOn = new List(); + if (options.LaunchMode == ProfilingCaptureLaunchMode.Launch) + dependsOn.Add("build-and-run"); if (diagnosticPortAddress is not null) dependsOn.Add("start-dsrouter"); if (dsrouterPlatformArg is null && diagnosticPortAddress is null && options.ProcessId is null) diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index 61489768..49e0a1d0 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -143,6 +143,9 @@ Suspend at startup while the diagnostic tools connect +
+ Pauses the app at startup until diagnostic tools connect. Use this when you need to capture events from the very beginning of the app's lifecycle. +
} @@ -287,33 +290,45 @@
-
- @foreach (var check in prerequisiteReport.Checks) - { -
-
- @check.Name - @check.Status -
-
@check.Message
-
- @check.Kind - @if (!string.IsNullOrWhiteSpace(check.InstalledVersion)) - { - Installed: @check.InstalledVersion - } - @if (!string.IsNullOrWhiteSpace(check.RequiredVersion)) + @if (!prerequisiteReport.HasErrors && !prerequisiteReport.HasWarnings) + { +
+ ✅ All prerequisites met + +
+ } + @if (prerequisiteReport.HasErrors || prerequisiteReport.HasWarnings || showPrereqDetails) + { +
+ @foreach (var check in prerequisiteReport.Checks) + { +
+
+ @check.Name + @check.Status +
+
@check.Message
+
+ @check.Kind + @if (!string.IsNullOrWhiteSpace(check.InstalledVersion)) + { + Installed: @check.InstalledVersion + } + @if (!string.IsNullOrWhiteSpace(check.RequiredVersion)) + { + Required: @check.RequiredVersion + } +
+ @if (!string.IsNullOrWhiteSpace(check.SuggestedCommand)) { - Required: @check.RequiredVersion + @check.SuggestedCommand }
- @if (!string.IsNullOrWhiteSpace(check.SuggestedCommand)) - { - @check.SuggestedCommand - } -
- } -
+ } +
+ }
} @@ -419,7 +434,15 @@ }
- @BuildCommandLine(step) +
+ Show Command + @BuildCommandLine(step) +
+ +
+
@if (step.DependsOn?.Count > 0 || step.RequiredRuntimeBindings?.Count > 0) {
@@ -433,11 +456,6 @@ }
} -
- -
} @@ -447,8 +465,8 @@ @foreach (var artifact in capturePlan.ExpectedArtifacts) {
-
@artifact.DisplayName
-
@artifact.RelativePath
+
@artifact.DisplayName
+
@System.IO.Path.GetFileName(artifact.RelativePath)
@artifact.Kind · @artifact.ContentType
} @@ -610,7 +628,7 @@ private string? processIdText; private string configuration = "Release"; private int diagnosticPort = 9000; - private bool suspendAtStartup = true; + private bool suspendAtStartup = false; private bool isRefreshingTargets; private bool isCheckingPrerequisites; private bool isPlanningCapture; @@ -622,6 +640,7 @@ private int currentStep = 0; private bool showAdvanced = false; + private bool showPrereqDetails = false; // Pipeline state private ProfilingPipelineState pipelineState = ProfilingPipelineState.NotStarted; @@ -647,7 +666,7 @@ 0 => launchMode == ProfilingCaptureLaunchMode.Attach || !string.IsNullOrWhiteSpace(projectPath), 1 => selectedCaptureKinds.Count > 0, 2 => SelectedTarget is not null, - 3 => capturePlan is not null && capturePlan.Validation.IsValid, + 3 => capturePlan is not null && capturePlan.Validation.IsValid && !isCheckingPrerequisites && !isPlanningCapture, _ => false }; @@ -856,6 +875,7 @@ private async Task CheckPrerequisitesAsync() { isCheckingPrerequisites = true; + UpdateWizardState(); StateHasChanged(); try @@ -871,6 +891,7 @@ finally { isCheckingPrerequisites = false; + UpdateWizardState(); StateHasChanged(); } } @@ -884,6 +905,7 @@ } isPlanningCapture = true; + UpdateWizardState(); StateHasChanged(); try @@ -1453,7 +1475,7 @@ diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index 6d93e2f7..1b9800df 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -158,6 +158,26 @@ Copy + @if (artifactErrors.TryGetValue(filePath, out var artifactError)) + { +
+
+ + @artifactError +
+
+ + + +
+
+ } } @@ -306,6 +326,9 @@ private string? speedscopeFilePath; private IJSObjectReference? speedscopeModule; + // Per-artifact error state (keyed by file path) + private readonly Dictionary artifactErrors = new(); + // GC dump viewer state private bool showGcDumpViewer; private bool gcDumpLoading; @@ -549,17 +572,37 @@ { try { + artifactErrors.Remove(path); string? speedscopePath = path; + if (path.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) { - speedscopePath = await ArtifactConverter.ConvertToSpeedscopeAsync(path); - if (speedscopePath is null) + // Check if a .speedscope.json already exists alongside the .nettrace + var baseName = path[..^".nettrace".Length]; + var existingSpeedscope = baseName + ".speedscope.json"; + if (File.Exists(existingSpeedscope)) + { + speedscopePath = existingSpeedscope; + } + else { - await AlertService.ShowToastAsync("Failed to convert trace to speedscope format."); - return; + speedscopePath = await ArtifactConverter.ConvertToSpeedscopeAsync(path); + if (speedscopePath is null) + { + artifactErrors[path] = "Failed to convert trace to speedscope format. Make sure dotnet-trace is installed."; + StateHasChanged(); + return; + } } } + if (!File.Exists(speedscopePath)) + { + artifactErrors[path] = "Conversion produced no output — the .speedscope.json file is missing."; + StateHasChanged(); + return; + } + speedscopeFilePath = speedscopePath; showSpeedscopeViewer = true; speedscopeLoading = true; @@ -567,7 +610,8 @@ } catch (Exception ex) { - await AlertService.ShowToastAsync($"Failed to open trace viewer: {ex.Message}"); + artifactErrors[path] = $"Failed to open trace viewer: {ex.Message}"; + StateHasChanged(); } } @@ -605,6 +649,12 @@ speedscopeLoading = false; } + private void DismissArtifactError(string path) + { + artifactErrors.Remove(path); + StateHasChanged(); + } + // GC dump viewer private async Task ViewGcDump(string path) { @@ -776,7 +826,7 @@ .session-card { background: var(--card-bg, var(--bg-secondary)); border: 1px solid var(--border-color); - border-radius: 10px; + border-radius: 14px; overflow: hidden; transition: box-shadow 0.15s; } @@ -869,7 +919,7 @@ .session-card-body { border-top: 1px solid var(--border-color); - padding: 1rem; + padding: 1rem 1rem 1.25rem; } .session-detail-row { @@ -901,6 +951,7 @@ .plan-section-title { font-weight: 700; + margin-top: 1rem; margin-bottom: 0.5rem; display: flex; align-items: center; @@ -915,14 +966,15 @@ .artifact-card { border: 1px solid var(--border-color); - border-radius: 10px; - padding: 0.85rem; + border-radius: 14px; + padding: 1rem; background: var(--bg-primary); } .result-artifact-card { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.75rem; } @@ -941,8 +993,12 @@ .artifact-actions { display: flex; + flex-wrap: wrap; gap: 0.4rem; - flex-shrink: 0; + flex-basis: 100%; + margin-top: 0.25rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border-color); } .artifact-meta { @@ -967,7 +1023,7 @@ width: 100%; height: 600px; border: 1px solid var(--border-color); - border-radius: 6px; + border-radius: 14px; overflow: hidden; margin-top: 0.75rem; } @@ -998,7 +1054,7 @@ gap: 1.5rem; padding: 0.75rem 1rem; background: var(--bg-tertiary); - border-radius: 6px; + border-radius: 14px; margin-top: 0.75rem; font-size: 0.875rem; color: var(--text-secondary); @@ -1018,7 +1074,7 @@ max-height: 500px; overflow-y: auto; border: 1px solid var(--border-color); - border-radius: 6px; + border-radius: 14px; } .gcdump-table { @@ -1107,4 +1163,28 @@ .gcdump-error { color: var(--text-warning, #eab308); } + + .artifact-error { + flex-basis: 100%; + margin-top: 0.25rem; + padding: 0.75rem; + border-top: 1px solid var(--text-warning, #eab308); + background: rgba(234, 179, 8, 0.08); + border-radius: 0 0 14px 14px; + } + + .artifact-error-message { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-warning, #eab308); + font-size: 0.8125rem; + margin-bottom: 0.5rem; + } + + .artifact-error-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } From aca2624c70679f222be2bed7f1dbbd136145314c Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 12:50:31 -0400 Subject: [PATCH 23/67] Split artifacts into profiling and log file sections Profiling artifacts (.nettrace, .gcdump, .speedscope.json) show in a primary always-visible section. Log files (.log) are grouped into a collapsible section that starts collapsed, keeping the view clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa/Pages/Profiling.razor | 182 ++++++++++++++++++--------- 1 file changed, 122 insertions(+), 60 deletions(-) diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index 1b9800df..24eb1ae9 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -115,74 +115,39 @@ } - @if (session.Artifacts.Count > 0) + @{ + var profilingArtifacts = session.Artifacts.Where(a => !a.FileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase)).ToList(); + var logArtifacts = session.Artifacts.Where(a => a.FileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + @if (profilingArtifacts.Count > 0) {
Artifacts
- @foreach (var artifact in session.Artifacts) + @foreach (var artifact in profilingArtifacts) { - var filePath = session.DirectoryPath is not null - ? Path.Combine(session.DirectoryPath, artifact.FileName) - : artifact.FileName; - var extension = GetArtifactExtension(artifact.FileName); - var isTraceFile = extension is ".nettrace" or ".speedscope.json"; - var isGcDump = extension is ".gcdump"; -
-
- -
-
-
@artifact.FileName
- @if (artifact.SizeBytes is not null) - { -
@FormatBytes(artifact.SizeBytes.Value)
- } -
-
- @if (isTraceFile) - { - - } - @if (isGcDump) - { - - } - - -
- @if (artifactErrors.TryGetValue(filePath, out var artifactError)) - { -
-
- - @artifactError -
-
- - - -
-
- } -
+ @RenderArtifactCard(session, artifact) }
} + @if (logArtifacts.Count > 0) + { +
+ + + Log Files + @logArtifacts.Count + +
+ @foreach (var artifact in logArtifacts) + { + @RenderArtifactCard(session, artifact) + } +
+
+ } + @* Inline Speedscope Viewer for session list *@ @if (showSpeedscopeViewer && speedscopeFilePath is not null) { @@ -543,6 +508,69 @@ catch { return null; } } + private RenderFragment RenderArtifactCard(ProfilingSessionManifest session, ProfilingSessionArtifact artifact) => __builder => + { + var filePath = session.DirectoryPath is not null + ? Path.Combine(session.DirectoryPath, artifact.FileName) + : artifact.FileName; + var extension = GetArtifactExtension(artifact.FileName); + var isTraceFile = extension is ".nettrace" or ".speedscope.json"; + var isGcDump = extension is ".gcdump"; + +
+
+ +
+
+
@artifact.FileName
+ @if (artifact.SizeBytes is not null) + { +
@FormatBytes(artifact.SizeBytes.Value)
+ } +
+
+ @if (isTraceFile) + { + + } + @if (isGcDump) + { + + } + + +
+ @if (artifactErrors.TryGetValue(filePath, out var artifactError)) + { +
+
+ + @artifactError +
+
+ + + +
+
+ } +
+ }; + private static string GetArtifactExtension(string path) { if (path.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) @@ -964,6 +992,40 @@ gap: 0.75rem; } + .log-files-section { + margin-top: 0.75rem; + } + + .log-files-section[open] .toggle-chevron { + transform: rotate(90deg); + } + + .log-files-toggle { + cursor: pointer; + user-select: none; + list-style: none; + font-size: 0.85rem; + color: var(--text-secondary); + } + + .log-files-toggle::-webkit-details-marker { + display: none; + } + + .toggle-chevron { + font-size: 0.65rem; + transition: transform 0.15s ease; + } + + .log-count-badge { + font-size: 0.7rem; + font-weight: 600; + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 0.1rem 0.45rem; + border-radius: 8px; + } + .artifact-card { border: 1px solid var(--border-color); border-radius: 14px; From 675324cb41c8a76ee292ce74f7f00ec899eb1646 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 12:57:06 -0400 Subject: [PATCH 24/67] Reorganize wizard: move Scenario & Suspend to Capture Kinds step - Move Scenario dropdown from Step 0 to Step 1 (Capture Kinds) as preset - Move Suspend at Startup from Step 0 Advanced to Step 1 - Auto-toggle suspend when Launch scenario selected - Rename Step 0 from 'Session Setup' to 'Project' - Reduce outer padding from 0.5rem to 0.25rem Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Modals/ProfilingCaptureWizardModal.razor | 104 ++++++++++++------ 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index 49e0a1d0..62d99ddf 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -57,7 +57,7 @@ {
- Session setup + Project
@@ -83,16 +83,6 @@
-
- - -
-
} - -
- -
- Pauses the app at startup until diagnostic tools connect. Use this when you need to capture events from the very beginning of the app's lifecycle. -
-
}
@@ -157,20 +137,43 @@
Capture kinds -
-
- @foreach (var captureKind in SupportedCaptureKinds) - { -
} @@ -654,7 +657,7 @@ private static readonly string[] WizardStepLabels = new[] { - "Session Setup", + "Project", "Capture Kinds", "Target", "Review & Plan", @@ -819,6 +822,8 @@ private Task OnScenarioChangedAsync() { ResetCaptureKindsToScenarioDefaults(); + // Auto-toggle suspend at startup based on scenario + suspendAtStartup = selectedScenario is ProfilingScenarioKind.Launch; prerequisiteReport = null; capturePlan = null; UpdateWizardState(); @@ -1475,7 +1480,7 @@ From 1e8efdafd017e36e1c43ccf9a43c490697cb8329 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:11:33 -0400 Subject: [PATCH 38/67] Stop duplicating prerequisite warnings in capture plan Prerequisite warnings (like 'Update available') are already shown in the Prerequisites section. Remove forwarding them into plan validation warnings to avoid redundant display. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/ProfilingCaptureOrchestrationService.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index b0c1c8db..972275e6 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -371,11 +371,6 @@ private static void AppendPrerequisiteFindings( { errors.Add(check.Message ?? $"{check.Name} is required for profiling orchestration."); } - - foreach (var check in prerequisites.Checks.Where(check => check.Status == DependencyStatusType.Warning)) - { - warnings.Add(check.Message ?? $"{check.Name} requires attention before profiling."); - } } private bool IsTargetCurrentlyAvailable(ProfilingTarget target) From ae9661fada51a44855d0547a2061df8257e2228e Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:13:20 -0400 Subject: [PATCH 39/67] Reduce input/select and Browse button sizing in wizard Decrease field padding and font-size for a more compact, consistent look. Make Browse button match input height with smaller padding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/Modals/ProfilingCaptureWizardModal.razor | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index 903ebfda..3ace0487 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -1640,7 +1640,8 @@ width: 100%; border: 1px solid var(--border-color); border-radius: 8px; - padding: 0.65rem 0.75rem; + padding: 0.45rem 0.65rem; + font-size: 0.85rem; background: var(--bg-secondary); color: var(--text-primary); } @@ -1652,13 +1653,20 @@ .input-row { display: flex; - gap: 0.6rem; + gap: 0.5rem; + align-items: stretch; } .input-row input { flex: 1; } + .input-row .btn { + font-size: 0.82rem; + padding: 0.4rem 0.7rem; + white-space: nowrap; + } + .checkbox-group { padding-top: 0.2rem; } From fd47cdd44b5acb11186bad7c2f89f3e888c6eb47 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:19:35 -0400 Subject: [PATCH 40/67] Move Stop Capture and Cancel to native footer buttons On step 4, the native primary button becomes 'Stop Capture' when the pipeline is waiting for stop. The native Cancel button cancels the pipeline and returns to step 3 without closing the modal (via new PreventClose bridge property). Remove redundant Blazor action buttons. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/Forms/HybridFormBridge.cs | 3 ++ src/MauiSherpa/Pages/Forms/WizardFormPage.cs | 1 + .../Modals/ProfilingCaptureWizardModal.razor | 50 +++++++++++-------- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs b/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs index 5bab4963..c8247101 100644 --- a/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs +++ b/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs @@ -42,6 +42,9 @@ public class HybridFormBridge public bool IsSubmitting { get; private set; } public string? SubmitText { get; private set; } + /// When true, native Cancel does not close the modal (Blazor handles it). + public bool PreventClose { get; set; } + /// Called by Blazor component to update form validity. public void SetValid(bool valid) { diff --git a/src/MauiSherpa/Pages/Forms/WizardFormPage.cs b/src/MauiSherpa/Pages/Forms/WizardFormPage.cs index 84905451..58e8a67e 100644 --- a/src/MauiSherpa/Pages/Forms/WizardFormPage.cs +++ b/src/MauiSherpa/Pages/Forms/WizardFormPage.cs @@ -284,6 +284,7 @@ private async void OnPrimaryClicked(object? sender, EventArgs e) private void OnCancelClicked(object? sender, EventArgs e) { _bridge.RequestCancel(); + if (_bridge.PreventClose) return; _bridgeHolder.Pop(); _tcs.TrySetResult(default); } diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index 3ace0487..d322fbd0 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -520,25 +520,11 @@ } - @if (pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) + @if (pipelineState == ProfilingPipelineState.Running) { -
- @if (pipelineState == ProfilingPipelineState.Running) - { -
- - Launching capture pipeline... -
- } - else - { - - } - +
+ + Launching capture pipeline...
} } @@ -621,6 +607,7 @@ bridge.SubmitRequested += OnSubmitRequested; bridge.BackRequested += OnBackRequested; bridge.NextRequested += OnNextRequested; + bridge.CancelRequested += OnCancelRequested; DeviceMonitor.Changed += OnDeviceMonitorChanged; @@ -642,13 +629,16 @@ if (bridge == null) return; var isCapturing = currentStep == 4 && pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop or ProfilingPipelineState.Completing; + var canStop = currentStep == 4 && pipelineState == ProfilingPipelineState.WaitingForStop; + + bridge.PreventClose = isCapturing; bridge.SetWizardState( showBack: currentStep > 0 && currentStep != 4, showNext: currentStep < 3, - showSubmit: currentStep == 3, - canProceed: CanProceedToNext && !isCapturing, - submitText: "Start Capture" + showSubmit: currentStep == 3 || canStop, + canProceed: canStop || (CanProceedToNext && !isCapturing), + submitText: canStop ? "Stop Capture" : "Start Capture" ); } @@ -686,6 +676,12 @@ private async Task OnSubmitRequested() { + if (currentStep == 4 && pipelineState == ProfilingPipelineState.WaitingForStop) + { + await HandleStopCapture(); + return; + } + if (currentStep == 3 && CanProceedToNext) { // Start Capture — advance to step 4 and run the pipeline @@ -1252,6 +1248,17 @@ StateHasChanged(); } + private void OnCancelRequested() + { + if (currentStep == 4 && pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) + { + InvokeAsync(() => + { + HandleCancelPipeline(); + }); + } + } + private async void ToggleStepLog(string stepId) { if (!expandedStepLogs.Remove(stepId)) @@ -1408,6 +1415,7 @@ bridge.SubmitRequested -= OnSubmitRequested; bridge.BackRequested -= OnBackRequested; bridge.NextRequested -= OnNextRequested; + bridge.CancelRequested -= OnCancelRequested; } DeviceMonitor.Changed -= OnDeviceMonitorChanged; From c5544295d33a80dae6cd8784081d9fbf4d449ea7 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:24:29 -0400 Subject: [PATCH 41/67] Fix trace files going to wrong directory Pass session storage path as OutputDirectory when generating the plan so commands and artifact RelativePaths are built with the correct path. Previously the plan was generated with a relative artifacts/ path and only the top-level OutputDirectory was overridden at pipeline start, leaving command arguments pointing to the old location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Modals/ProfilingCaptureWizardModal.razor | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index d322fbd0..668f3243 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -856,17 +856,6 @@ var (_, plan) = await Mediator.Request(new PlanProfilingCaptureRequest(definition, BuildPlanOptions(SelectedTarget))); - // Pre-compute session storage path so the plan preview shows the actual output location - if (string.IsNullOrWhiteSpace(outputDirectory)) - { - var projectName = projectPath is not null - ? Path.GetFileNameWithoutExtension(projectPath) - : null; - var previewSessionId = SessionStorage.GenerateSessionId(projectName); - var sessionDir = SessionStorage.GetSessionDirectoryPath(previewSessionId); - plan = plan with { OutputDirectory = sessionDir }; - } - capturePlan = plan; } catch (Exception ex) @@ -901,11 +890,22 @@ if (int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) processId = parsedProcessId; + // Pre-compute session storage path so commands are built with correct output dir + var effectiveOutputDir = outputDirectory; + if (string.IsNullOrWhiteSpace(effectiveOutputDir)) + { + var projectName = projectPath is not null + ? Path.GetFileNameWithoutExtension(projectPath) + : null; + var previewSessionId = SessionStorage.GenerateSessionId(projectName); + effectiveOutputDir = SessionStorage.GetSessionDirectoryPath(previewSessionId); + } + return new ProfilingCapturePlanOptions( ProjectPath: string.IsNullOrWhiteSpace(projectPath) ? null : projectPath, Configuration: configuration, TargetFramework: string.IsNullOrWhiteSpace(targetFrameworkOverride) ? null : targetFrameworkOverride, - OutputDirectory: string.IsNullOrWhiteSpace(outputDirectory) ? null : outputDirectory, + OutputDirectory: effectiveOutputDir, LaunchMode: launchMode, DiagnosticPort: diagnosticPort, SuspendAtStartup: suspendAtStartup, @@ -1027,13 +1027,9 @@ { if (capturePlan is null) return; - var projectName = projectPath is not null - ? Path.GetFileNameWithoutExtension(projectPath) - : null; - activeSessionId = SessionStorage.GenerateSessionId(projectName); - var sessionDir = SessionStorage.GetSessionDirectoryPath(activeSessionId); - - capturePlan = capturePlan with { OutputDirectory = sessionDir }; + // The plan already has the correct output directory from BuildPlanOptions. + // Derive the session ID from the output directory name. + activeSessionId = Path.GetFileName(capturePlan.OutputDirectory); pipelineState = ProfilingPipelineState.NotStarted; pipelineResult = null; From 6f951e9b19aac4521347c9c6cb7f4db7514a291b Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:26:16 -0400 Subject: [PATCH 42/67] Compact artifact list: rows with inline actions Replace large artifact cards with a compact bordered list. Each artifact is a single row with icon, filename, size, and small action buttons on the right. View buttons stay labeled; Reveal/Copy are icon-only with tooltips. Error rows nest below the relevant artifact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa/Pages/Profiling.razor | 164 +++++++++++++++++---------- 1 file changed, 101 insertions(+), 63 deletions(-) diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index c2c63af4..63dd06ed 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -513,59 +513,53 @@ var extension = GetArtifactExtension(artifact.FileName); var isTraceFile = extension is ".nettrace" or ".speedscope.json"; var isGcDump = extension is ".gcdump"; + var hasViewer = isTraceFile || isGcDump; -
-
+
+
-
-
@artifact.FileName
+
+ @artifact.FileName @if (artifact.SizeBytes is not null) { -
@FormatBytes(artifact.SizeBytes.Value)
+ @FormatBytes(artifact.SizeBytes.Value) }
-
+
@if (isTraceFile) { - } @if (isGcDump) { - } - -
- @if (artifactErrors.TryGetValue(filePath, out var artifactError)) - { -
-
- - @artifactError -
-
- - - -
-
- }
+ @if (artifactErrors.TryGetValue(filePath, out var artifactError)) + { +
+ + @artifactError + + +
+ } }; private static string GetArtifactExtension(string path) @@ -984,9 +978,12 @@ } .artifact-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); - gap: 0.75rem; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: 10px; + overflow: hidden; + background: var(--bg-primary); } .log-files-section { @@ -1023,50 +1020,91 @@ border-radius: 8px; } - .artifact-card { - border: 1px solid var(--border-color); - border-radius: 14px; - padding: 1rem; - background: var(--bg-primary); - } - - .result-artifact-card { + .artifact-row { display: flex; align-items: center; - flex-wrap: wrap; - gap: 0.75rem; + gap: 0.6rem; + padding: 0.5rem 0.75rem; } - .artifact-icon { - font-size: 1.25rem; + .artifact-row:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + + .artifact-row-icon { + font-size: 0.9rem; color: var(--text-secondary); - width: 2rem; + width: 1.5rem; text-align: center; flex-shrink: 0; } - .artifact-details { + .artifact-row-info { flex: 1; min-width: 0; + display: flex; + align-items: baseline; + gap: 0.5rem; } - .artifact-actions { + .artifact-row-name { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .artifact-row-size { + font-size: 0.7rem; + color: var(--text-tertiary); + white-space: nowrap; + } + + .artifact-row-actions { display: flex; - flex-wrap: wrap; - gap: 0.4rem; - flex-basis: 100%; - margin-top: 0.25rem; - padding-top: 0.5rem; - border-top: 1px solid var(--border-color); + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + } + + .btn-xs { + padding: 0.2rem 0.55rem; + font-size: 0.7rem; + border-radius: 6px; + } + + .btn-icon-sm { + background: transparent; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 0.25rem 0.35rem; + border-radius: 6px; + font-size: 0.75rem; + transition: color 0.15s, background 0.15s; } - .artifact-meta { + .btn-icon-sm:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + } + + .artifact-error-row { display: flex; - flex-wrap: wrap; - gap: 0.4rem 0.8rem; - margin-top: 0.55rem; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem 0.35rem 2.85rem; font-size: 0.75rem; - color: var(--text-tertiary); + color: var(--text-secondary); + background: rgba(234, 179, 8, 0.06); + border-bottom: 1px solid var(--border-color); + } + + .artifact-error-row span { + flex: 1; + min-width: 0; } .missing-artifact { opacity: 0.7; } From 0bb2cd77c08374cb2e2e1b71cb8d9f2f506d6150 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:34:57 -0400 Subject: [PATCH 43/67] Fix Stop Capture closing modal before session is saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native Submit button (renamed to 'Stop Capture') always closed the modal after the handler returned. But HandleStopCapture just signals the pipeline to stop — the actual completion, artifact collection, and session saving happen asynchronously in StartPipelineAsync. Add PreventSubmitClose to HybridFormBridge so the native Submit handler does not pop the bridge and close the modal while the pipeline is still active. After the pipeline completes and the session is saved, the Blazor component calls bridge.RequestClose() to close the modal with the manifest as the result. Also adds CloseRequested event to HybridFormBridge for Blazor components to programmatically close the modal with a result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa/Pages/Forms/HybridFormBridge.cs | 9 +++++++++ src/MauiSherpa/Pages/Forms/WizardFormPage.cs | 12 ++++++++++++ .../Pages/Modals/ProfilingCaptureWizardModal.razor | 5 +++++ 3 files changed, 26 insertions(+) diff --git a/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs b/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs index c8247101..418d6b50 100644 --- a/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs +++ b/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs @@ -45,6 +45,9 @@ public class HybridFormBridge /// When true, native Cancel does not close the modal (Blazor handles it). public bool PreventClose { get; set; } + /// When true, native Submit/Primary does not close the modal after the handler completes. + public bool PreventSubmitClose { get; set; } + /// Called by Blazor component to update form validity. public void SetValid(bool valid) { @@ -81,9 +84,15 @@ public async Task RequestActionAsync(string actionId) await ActionRequested.Invoke(actionId); } + /// Fired by the Blazor component to programmatically close the modal with a result. + public event Action? CloseRequested; + /// Called by MAUI page when native cancel button is pressed. public void RequestCancel() => CancelRequested?.Invoke(); + /// Called by Blazor component to close the modal with the current Result. + public void RequestClose() => CloseRequested?.Invoke(); + /// Called by MAUI page when native back button is pressed. public async Task RequestBackAsync() { diff --git a/src/MauiSherpa/Pages/Forms/WizardFormPage.cs b/src/MauiSherpa/Pages/Forms/WizardFormPage.cs index 58e8a67e..051a2f38 100644 --- a/src/MauiSherpa/Pages/Forms/WizardFormPage.cs +++ b/src/MauiSherpa/Pages/Forms/WizardFormPage.cs @@ -61,6 +61,7 @@ private void BuildPage() // Listen for wizard state changes from Blazor _bridge.WizardStateChanged += OnWizardStateChanged; + _bridge.CloseRequested += OnCloseRequested; // Title var titleLabel = new Label @@ -266,6 +267,7 @@ private async void OnPrimaryClicked(object? sender, EventArgs e) try { await _bridge.RequestSubmitAsync(); + if (_bridge.PreventSubmitClose) return; var result = (TResult?)_bridge.Result; _bridgeHolder.Pop(); _tcs.TrySetResult(result); @@ -289,6 +291,16 @@ private void OnCancelClicked(object? sender, EventArgs e) _tcs.TrySetResult(default); } + private void OnCloseRequested() + { + Dispatcher.Dispatch(() => + { + var result = (TResult?)_bridge.Result; + _bridgeHolder.Pop(); + _tcs.TrySetResult(result); + }); + } + private void OnBlazorReady(double contentHeight) { ModalWebViewReadySignal.Ready -= OnBlazorReady; diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index 668f3243..3ab1c445 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -632,6 +632,7 @@ var canStop = currentStep == 4 && pipelineState == ProfilingPipelineState.WaitingForStop; bridge.PreventClose = isCapturing; + bridge.PreventSubmitClose = isCapturing; bridge.SetWizardState( showBack: currentStep > 0 && currentStep != 4, @@ -1062,7 +1063,11 @@ { var bridge = BridgeHolder.Current; if (bridge != null) + { bridge.Result = manifest; + bridge.RequestClose(); + return; + } } } catch (Exception ex) From a803ab829f3ba54549d513b8947b47235e1eaf32 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:38:10 -0400 Subject: [PATCH 44/67] Fix GC dump running before app is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline executor treated a long-running dependency as satisfied the moment it started. This was fine for dotnet-trace (which itself waits for the app to connect via the diagnostic port) but broke dotnet-gcdump, which connects, collects, and exits immediately — before the app had even finished building. Changes: - Add IsReady property to ProfilingStepStatus. Long-running steps signal readiness when their output matches a configurable ReadyOutputPattern. - Add ReadyOutputPattern to ProfilingCommandStep. Set to 'Process' for dotnet-trace (emitted when the app connects) and 'Build succeeded' for the build-and-run step. - Refine dependency logic: non-long-running steps (like gcdump) now wait for their long-running dependencies to signal IsReady, not just start. Long-running steps still proceed as soon as the dependency starts. - Make capture-memory depend on capture-trace when both are requested, so gcdump waits for trace to establish its diagnostic port connection. - Poll with 500ms delay when waiting for readiness instead of blocking on task completion only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProfilingCaptureOrchestrationModels.cs | 3 +- .../Profiling/ProfilingPipelineModels.cs | 7 +++ .../ProfilingCaptureOrchestrationService.cs | 16 +++++-- .../Services/ProfilingSessionRunnerService.cs | 45 ++++++++++++++++--- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs index 67b643a5..e0315748 100644 --- a/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs @@ -96,7 +96,8 @@ public record ProfilingCommandStep( bool IsLongRunning = false, bool RequiresManualStop = false, bool CanRunParallel = false, - ProfilingStopTrigger StopTrigger = ProfilingStopTrigger.None) + ProfilingStopTrigger StopTrigger = ProfilingStopTrigger.None, + string? ReadyOutputPattern = null) { public string CommandLine => Arguments.Count > 0 ? $"{Command} {string.Join(" ", Arguments)}" diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs index 5b95bdca..824b4640 100644 --- a/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs @@ -42,6 +42,13 @@ public class ProfilingStepStatus public bool IsLongRunning { get; init; } public bool CanRunParallel { get; init; } public ProfilingStopTrigger StopTrigger { get; init; } + + /// + /// True when a long-running step has established its connection and is + /// actively capturing. Used to gate dependent non-long-running steps + /// (e.g. gcdump waits for trace to connect before running). + /// + public bool IsReady { get; set; } } public record ProfilingStepOutputLine(string Text, bool IsError, DateTime Timestamp); diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index 972275e6..8af728e4 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -183,7 +183,8 @@ normalizedOptions.ProcessId is null && gcdumpArtifactPath, runtimeBindings, needsStandaloneDsRouter ? diagnostics?.IpcAddress : null, - androidSdkPath); + androidSdkPath, + hasTraceCapture); postLaunchCaptureSteps.Add(memoryStep); expectedArtifacts.Add(memoryArtifact); @@ -548,7 +549,8 @@ private static ProfilingCommandStep CreateLaunchStep( IsLongRunning: true, RequiresManualStop: definition.Target.Platform is ProfilingTargetPlatform.MacCatalyst or ProfilingTargetPlatform.MacOS or ProfilingTargetPlatform.Windows, CanRunParallel: true, - StopTrigger: ProfilingStopTrigger.OnPipelineStop); + StopTrigger: ProfilingStopTrigger.OnPipelineStop, + ReadyOutputPattern: "Build succeeded"); } /// @@ -699,7 +701,8 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C IsLongRunning: true, RequiresManualStop: true, CanRunParallel: true, - StopTrigger: ProfilingStopTrigger.ManualStop), + StopTrigger: ProfilingStopTrigger.ManualStop, + ReadyOutputPattern: "Process"), new ProfilingArtifactMetadata( Id: $"{definition.Id}-trace", SessionId: definition.Id, @@ -719,7 +722,8 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C string gcdumpArtifactPath, List runtimeBindings, string? diagnosticPortAddress = null, - string? androidSdkPath = null) + string? androidSdkPath = null, + bool hasTraceCapture = false) { var arguments = new List { @@ -760,6 +764,10 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C dependsOn.Add("start-dsrouter"); if (dsrouterPlatformArg is null && diagnosticPortAddress is null && options.ProcessId is null) dependsOn.Add("discover-process-id"); + // When both trace and memory are requested, wait for the trace step to + // establish its diagnostic port connection before collecting the GC dump. + if (hasTraceCapture) + dependsOn.Add("capture-trace"); return ( new ProfilingCommandStep( diff --git a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs index d3688786..26fe4293 100644 --- a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs +++ b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs @@ -141,18 +141,27 @@ private async Task ExecutePipelineAsync(IReadOnlyList comm { ct.ThrowIfCancellationRequested(); - // Find steps whose dependencies are all satisfied + // Find steps whose dependencies are all satisfied. + // Long-running steps (like dotnet-trace) can proceed when their dependency + // is merely started, because they themselves wait for the app to connect. + // Non-long-running steps (like dotnet-gcdump) must wait until their + // long-running dependency signals IsReady (app is actually connected). var ready = commands .Where(c => remaining.Contains(c.Id)) .Where(c => c.DependsOn is null || c.DependsOn.All(dep => - completed.Contains(dep) || IsLongRunningAndStarted(dep))) + completed.Contains(dep) || IsDependencySatisfied(dep, c.IsLongRunning))) .ToList(); if (ready.Count == 0) { if (longRunningTasks.Count > 0) { - await Task.WhenAny(longRunningTasks.Values); + // Wait briefly then re-check — a long-running step may signal + // IsReady at any time via its output, unblocking dependent steps. + var completedTask = await Task.WhenAny( + Task.WhenAny(longRunningTasks.Values), + Task.Delay(500, ct)); + foreach (var (id, task) in longRunningTasks.ToList()) { if (task.IsCompleted) @@ -240,10 +249,25 @@ private async Task ExecutePipelineAsync(IReadOnlyList comm } } - private bool IsLongRunningAndStarted(string stepId) + /// + /// Checks if a dependency is satisfied for a dependent step. + /// Long-running dependents (e.g. dotnet-trace) can proceed when the dependency + /// is just started — they themselves wait for the app. Non-long-running dependents + /// (e.g. dotnet-gcdump) must wait for IsReady, meaning the dependency has + /// established its connection and the app is actually available. + /// + private bool IsDependencySatisfied(string depStepId, bool dependentIsLongRunning) { - var status = _steps.FirstOrDefault(s => s.StepId == stepId); - return status is { IsLongRunning: true, State: ProfilingStepState.Running }; + var status = _steps.FirstOrDefault(s => s.StepId == depStepId); + if (status is null) return false; + if (!status.IsLongRunning) return false; + if (status.State != ProfilingStepState.Running) return false; + + // Long-running dependents can proceed as soon as the dep is started + if (dependentIsLongRunning) return true; + + // Non-long-running dependents must wait for the dep to signal readiness + return status.IsReady; } private async Task LaunchStepAsync(ProfilingCommandStep step, CancellationToken ct) @@ -276,6 +300,15 @@ private async Task LaunchStepAsync(ProfilingCommandStep step, CancellationToken Text = e.Data, IsError = e.IsError }); + + // Detect readiness for long-running steps by matching output patterns + if (step.IsLongRunning && !status.IsReady && !e.IsError + && step.ReadyOutputPattern is not null + && e.Data.Contains(step.ReadyOutputPattern, StringComparison.OrdinalIgnoreCase)) + { + status.IsReady = true; + _logger.LogDebug($"Step '{step.Id}' is ready (matched: {step.ReadyOutputPattern})"); + } }; try From 8cb7d8a5f7b1cbc505e4ca746b4150dec9639829 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 15:53:27 -0400 Subject: [PATCH 45/67] Open artifact viewers in separate windows Move speedscope and GC dump viewers from inline rendering on the Profiling page to dedicated Blazor pages (SpeedscopeViewer.razor, GcDumpViewer.razor) that open in their own macOS windows via ProfilingViewerService. Each viewer type gets its own window; clicking View again replaces the previous viewer window. Removes ~200 lines of inline viewer HTML, CSS, and state management from Profiling.razor. The viewer pages are self-contained with their own loading, error, and content states. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.MacOS/MacOSMauiProgram.cs | 1 + src/MauiSherpa/Pages/GcDumpViewer.razor | 331 ++++++++++++++ src/MauiSherpa/Pages/Profiling.razor | 410 +----------------- src/MauiSherpa/Pages/SpeedscopeViewer.razor | 154 +++++++ .../Services/ProfilingViewerService.cs | 46 ++ 5 files changed, 539 insertions(+), 403 deletions(-) create mode 100644 src/MauiSherpa/Pages/GcDumpViewer.razor create mode 100644 src/MauiSherpa/Pages/SpeedscopeViewer.razor create mode 100644 src/MauiSherpa/Services/ProfilingViewerService.cs diff --git a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs index 255d8106..42424c98 100644 --- a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs +++ b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs @@ -107,6 +107,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/Pages/GcDumpViewer.razor b/src/MauiSherpa/Pages/GcDumpViewer.razor new file mode 100644 index 00000000..54acfcb9 --- /dev/null +++ b/src/MauiSherpa/Pages/GcDumpViewer.razor @@ -0,0 +1,331 @@ +@page "/profiling/gcdump" +@using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Models.Profiling +@inject IGcDumpReportService GcDumpReportService + +
+
+ + GC Dump — Heap Statistics + @if (!string.IsNullOrEmpty(fileName)) + { + @fileName + } +
+ +
+ @if (loading) + { +
+ +
Analyzing GC dump...
+
+ } + else if (errorMessage is not null) + { +
+ + @errorMessage +
+ } + else if (report is not null) + { +
+
+ @report.TotalCount.ToString("N0") objects + @FormatBytes(report.TotalSize) + @report.Types.Count types +
+
+ +
+
+ +
+ + + + + + + + + + + @foreach (var entry in GetFilteredTypes()) + { + var pct = report.TotalSize > 0 + ? (double)entry.Size / report.TotalSize * 100 + : 0; + + + + + + + } + +
+ Type Name + @if (sortColumn == "name") { } + + Count + @if (sortColumn == "count") { } + + Total Size + @if (sortColumn == "size") { } + %
+ @entry.TypeName + @entry.Count.ToString("N0")@FormatBytes(entry.Size) +
+
+ @pct.ToString("F1")% +
+
+
+ } +
+
+ +@code { + [SupplyParameterFromQuery] public string? file { get; set; } + + private string? filePath; + private string? fileName; + private bool loading = true; + private string? errorMessage; + private GcDumpReport? report; + private string filterText = ""; + private string sortColumn = "size"; + private bool sortAsc = false; + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrEmpty(file)) + { + loading = false; + errorMessage = "No file path specified."; + return; + } + + filePath = Uri.UnescapeDataString(file); + fileName = Path.GetFileName(filePath); + + try + { + report = await GcDumpReportService.GetReportAsync(filePath); + if (report is null) + errorMessage = "Failed to parse GC dump. Make sure dotnet-gcdump is installed."; + } + catch (Exception ex) + { + errorMessage = $"Error analyzing GC dump: {ex.Message}"; + } + finally + { + loading = false; + } + } + + private IEnumerable GetFilteredTypes() + { + if (report is null) return Enumerable.Empty(); + + var types = report.Types.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(filterText)) + types = types.Where(t => t.TypeName.Contains(filterText, StringComparison.OrdinalIgnoreCase)); + + types = sortColumn switch + { + "name" => sortAsc ? types.OrderBy(t => t.TypeName) : types.OrderByDescending(t => t.TypeName), + "count" => sortAsc ? types.OrderBy(t => t.Count) : types.OrderByDescending(t => t.Count), + "size" => sortAsc ? types.OrderBy(t => t.Size) : types.OrderByDescending(t => t.Size), + _ => types + }; + + return types.Take(500); + } + + private void Sort(string column) + { + if (sortColumn == column) + sortAsc = !sortAsc; + else + { + sortColumn = column; + sortAsc = column == "name"; + } + } + + private static string FormatBytes(long bytes) + { + if (bytes >= 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):F1} MB"; + if (bytes >= 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes:N0} B"; + } +} + + diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index 63dd06ed..b7971c37 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -6,12 +6,11 @@ @inject IToolbarService ToolbarService @inject IDialogService DialogService @inject IPlatformService Platform -@inject IGcDumpReportService GcDumpReportService @inject IProfilingArtifactConverterService ArtifactConverter @inject IProfilingSessionStorageService SessionStorage @inject IFormModalService FormModal @inject HybridFormBridgeHolder BridgeHolder -@inject IJSRuntime JS +@inject MauiSherpa.Services.ProfilingViewerService ViewerService @implements IDisposable @@ -145,117 +144,6 @@ } - @* Inline Speedscope Viewer for session list *@ - @if (showSpeedscopeViewer && speedscopeFilePath is not null) - { -
- CPU Trace Viewer - -
-
- @if (speedscopeLoading) - { -
- -
Loading trace viewer...
-
- } - -
- } - - @* Inline GC Dump Viewer for session list *@ - @if (showGcDumpViewer && gcDumpReport is not null) - { -
- GC Dump — Heap Statistics - -
-
- @gcDumpReport.TotalCount.ToString("N0") objects - @FormatBytes(gcDumpReport.TotalSize) - @gcDumpReport.Types.Count types -
-
- -
-
- - - - - - - - - - - @foreach (var entry in GetFilteredGcDumpTypes()) - { - var pct = gcDumpReport.TotalSize > 0 - ? (double)entry.Size / gcDumpReport.TotalSize * 100 - : 0; - - - - - - - } - -
- Type Name - @if (gcDumpSortColumn == "name") { } - - Count - @if (gcDumpSortColumn == "count") { } - - Total Size - @if (gcDumpSortColumn == "size") { } - %
- @entry.TypeName - @entry.Count.ToString("N0")@FormatBytes(entry.Size) -
-
- @pct.ToString("F1")% -
-
-
- } - else if (showGcDumpViewer && gcDumpLoading) - { -
- GC Dump Analysis -
-
- -
Analyzing GC dump...
-
- } - else if (showGcDumpViewer && gcDumpError is not null) - { -
- GC Dump Analysis - -
-
- - @gcDumpError -
- } -
void Cancel(); + + /// + /// Collect a GC dump on demand while the pipeline is running. + /// Returns the path to the .gcdump file, or null if collection failed. + /// + Task CollectGcDumpAsync(CancellationToken ct = default); } /// diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index 447e0386..d79b3e59 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -176,7 +176,7 @@ normalizedOptions.ProcessId is null && if (hasMemoryCapture) { - var (memoryStep, memoryArtifact) = CreateMemoryCaptureStep( + var (_, memoryArtifact) = CreateMemoryCaptureStep( definition, normalizedOptions, dsrouterPlatformArg, @@ -186,7 +186,8 @@ normalizedOptions.ProcessId is null && androidSdkPath, hasTraceCapture); - postLaunchCaptureSteps.Add(memoryStep); + // Don't add memoryStep to pipeline — GC dump is a point-in-time snapshot + // that users trigger on demand via the capture UI, not auto-run. expectedArtifacts.Add(memoryArtifact); } diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index 3ab1c445..ad641d69 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -520,6 +520,30 @@ }
+ @if (pipelineState == ProfilingPipelineState.WaitingForStop && selectedCaptureKinds.Contains(ProfilingCaptureKind.Memory)) + { +
+ + @if (gcDumpCount > 0) + { + @gcDumpCount snapshot@(gcDumpCount != 1 ? "s" : "") collected + } +
+ } + @if (pipelineState == ProfilingPipelineState.Running) {
@@ -571,6 +595,8 @@ private DateTime pipelineStartTime; private HashSet expandedStepLogs = new(); private HashSet expandedCommands = new(); + private bool isCollectingGcDump; + private int gcDumpCount; private string? activeSessionId; @@ -1240,6 +1266,36 @@ await PipelineRunner.StopCaptureAsync(); } + private async Task CollectGcDumpOnDemandAsync() + { + if (isCollectingGcDump) return; + isCollectingGcDump = true; + StateHasChanged(); + + try + { + var path = await PipelineRunner.CollectGcDumpAsync(); + if (path is not null) + { + gcDumpCount++; + await AlertService.ShowToastAsync($"GC dump #{gcDumpCount} collected"); + } + else + { + await AlertService.ShowToastAsync("GC dump collection failed"); + } + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"GC dump failed: {ex.Message}"); + } + finally + { + isCollectingGcDump = false; + StateHasChanged(); + } + } + private void HandleCancelPipeline() { PipelineRunner.Cancel(); @@ -2109,6 +2165,25 @@ gap: 0.4rem; } + .gcdump-action-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0; + border-top: 1px solid var(--border-secondary); + } + + .gcdump-action-bar .btn { + display: flex; + align-items: center; + gap: 0.35rem; + } + + .gcdump-count { + font-size: 0.8rem; + color: var(--text-secondary); + } + .text-success { color: #22c55e; } .text-danger { color: #ef4444; } .text-warning { color: #eab308; } diff --git a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs index 26fe4293..02167154 100644 --- a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs +++ b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs @@ -17,6 +17,7 @@ public class ProfilingSessionRunnerService : IProfilingSessionRunner private string? _outputDirectory; private DateTime _startTime; private volatile bool _stopRequested; + private int _gcDumpCount; public ProfilingPipelineState State => _state; public IReadOnlyList Steps => _steps; @@ -37,6 +38,7 @@ public async Task RunAsync(ProfilingCapturePlan plan, C _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _startTime = DateTime.Now; _stopRequested = false; + _gcDumpCount = 0; if (!string.IsNullOrWhiteSpace(plan.OutputDirectory)) Directory.CreateDirectory(plan.OutputDirectory); @@ -471,6 +473,104 @@ private void SetStepState(ProfilingStepStatus status, ProfilingStepState newStat }); } + public async Task CollectGcDumpAsync(CancellationToken ct = default) + { + if (_plan is null || _outputDirectory is null || _state != ProfilingPipelineState.WaitingForStop) + { + _logger.LogWarning("Cannot collect GC dump: pipeline is not in WaitingForStop state."); + return null; + } + + var dumpNumber = Interlocked.Increment(ref _gcDumpCount); + var fileName = $"memory-{dumpNumber}.gcdump"; + var outputPath = Path.Combine(_outputDirectory, fileName); + + // Build arguments: reuse the diagnostic connection info from the running plan + var arguments = new List { "collect" }; + var diagnostics = _plan.Diagnostics; + var options = _plan.Options; + + if (diagnostics?.IpcAddress is not null) + { + // Standalone dsrouter mode — connect via IPC + arguments.Add("--diagnostic-port"); + arguments.Add($"{diagnostics.IpcAddress},connect"); + } + else + { + // Desktop mode — use process ID discovered at runtime + var discoveredPid = _steps + .FirstOrDefault(s => s.StepId == "discover-process-id") + ?.OutputLines + .LastOrDefault(l => !l.IsError) + ?.Text; + + // Try to get PID from the build-and-run step's process + var buildStep = _stepProcesses.GetValueOrDefault("build-and-run"); + var pid = options.ProcessId + ?? (discoveredPid is not null && int.TryParse(discoveredPid.Trim(), out var parsed) ? parsed : (int?)null) + ?? buildStep?.ProcessId; + + if (pid is null) + { + _logger.LogWarning("Cannot collect GC dump: no process ID available."); + Interlocked.Decrement(ref _gcDumpCount); + return null; + } + + arguments.Add("--process-id"); + arguments.Add(pid.ToString()!); + } + + arguments.Add("-o"); + arguments.Add(outputPath); + + var stepId = $"gcdump-{dumpNumber}"; + var step = new ProfilingCommandStep( + Id: stepId, + Kind: ProfilingCommandStepKind.CollectArtifacts, + DisplayName: $"GC dump #{dumpNumber}", + Description: $"On-demand heap snapshot #{dumpNumber}.", + Command: "dotnet-gcdump", + Arguments: arguments, + WorkingDirectory: options.WorkingDirectory, + IsLongRunning: false, + RequiresManualStop: false, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "dotnet-gcdump", + ["output"] = outputPath + }); + + var status = new ProfilingStepStatus + { + StepId = stepId, + DisplayName = step.DisplayName, + Kind = step.Kind, + IsLongRunning = false, + CanRunParallel = false, + StopTrigger = ProfilingStopTrigger.None + }; + _steps.Add(status); + StepStateChanged?.Invoke(this, new ProfilingStepStateChangedEventArgs + { + StepId = stepId, + OldState = ProfilingStepState.Pending, + NewState = ProfilingStepState.Pending + }); + + try + { + await LaunchStepAsync(step, ct); + return File.Exists(outputPath) ? outputPath : null; + } + catch (Exception ex) + { + _logger.LogWarning($"On-demand GC dump failed: {ex.Message}"); + return null; + } + } + public void Dispose() { _cts?.Cancel(); diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs index fac7f151..132fdd8f 100644 --- a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs @@ -69,10 +69,12 @@ public async Task PlanCaptureAsync_AndroidEmulatorLaunch_UsesDsRouterServerServe "start-dsrouter", "setup-diagnostic-port", "build-and-run", - "capture-trace", - "capture-memory"); + "capture-trace"); plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); + // GC dump is on-demand, not in the pipeline commands, but still an expected artifact + plan.ExpectedArtifacts.Should().Contain(a => a.DisplayName == "GC dump"); + // The adb setprop step configures the Mono diagnostic port var setupStep = plan.Commands.Single(command => command.Id == "setup-diagnostic-port"); setupStep.CommandLine.Should().Contain("debug.mono.profile"); @@ -91,10 +93,6 @@ public async Task PlanCaptureAsync_AndroidEmulatorLaunch_UsesDsRouterServerServe traceStep.CommandLine.Should().NotContain("--dsrouter"); traceStep.CanRunParallel.Should().BeTrue(); traceStep.StopTrigger.Should().Be(ProfilingStopTrigger.ManualStop); - - var memoryStep = plan.Commands.Single(command => command.Id == "capture-memory"); - memoryStep.CommandLine.Should().Contain("--diagnostic-port"); - memoryStep.CommandLine.Should().NotContain("--dsrouter"); } [Fact] @@ -132,18 +130,16 @@ public async Task PlanCaptureAsync_IosPhysicalDeviceLaunch_UsesDsRouterServerCli plan.Commands.Select(command => command.Id).Should().ContainInOrder( "start-dsrouter", "build-and-run", - "capture-trace", - "capture-memory"); + "capture-trace"); plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); + // GC dump is on-demand, not in the pipeline commands, but still an expected artifact + plan.ExpectedArtifacts.Should().Contain(a => a.DisplayName == "GC dump"); + var traceStep = plan.Commands.Single(command => command.Id == "capture-trace"); traceStep.CommandLine.Should().Contain("--diagnostic-port"); traceStep.CommandLine.Should().NotContain("--dsrouter"); - var memoryStep = plan.Commands.Single(command => command.Id == "capture-memory"); - memoryStep.CommandLine.Should().Contain("--diagnostic-port"); - memoryStep.CommandLine.Should().NotContain("--dsrouter"); - var buildStep = plan.Commands.Single(command => command.Id == "build-and-run"); buildStep.CommandLine.Should().Contain("-f net10.0-ios"); buildStep.CommandLine.Should().NotContain("-p:DiagnosticListenMode"); From 0257ad319722104e6f223284f276581d25e1eb60 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 18:07:43 -0400 Subject: [PATCH 48/67] Add macOS AppKit implementation for ShowInputDialogAsync The DialogService.ShowInputDialogAsync was wrapped in #if MACCATALYST and the #else branch returned null, so the keystore password prompt never appeared on macOS AppKit builds. Add a MACOSAPP implementation using NSAlert with an accessory NSTextField (NSSecureTextField for password prompts). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa/Services/DialogService.cs | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/MauiSherpa/Services/DialogService.cs b/src/MauiSherpa/Services/DialogService.cs index 48086a76..b084ca9f 100644 --- a/src/MauiSherpa/Services/DialogService.cs +++ b/src/MauiSherpa/Services/DialogService.cs @@ -63,6 +63,42 @@ await Dispatcher.DispatchAsync(() => viewController?.PresentViewController(alertController, true, null); }); + return await tcs.Task; +#elif MACOSAPP + var tcs = new TaskCompletionSource(); + + await Dispatcher.DispatchAsync(() => + { + var alert = new NSAlert + { + AlertStyle = NSAlertStyle.Informational, + MessageText = title, + InformativeText = message + }; + + var isPassword = title.Contains("Password", StringComparison.OrdinalIgnoreCase); + NSTextField input; + if (isPassword) + input = NSSecureTextField.CreateTextField(string.Empty); + else + input = NSTextField.CreateTextField(string.Empty); + + input.PlaceholderString = placeholder; + input.Frame = new CoreGraphics.CGRect(0, 0, 260, 24); + alert.AccessoryView = input; + + alert.AddButton("OK"); + alert.AddButton("Cancel"); + + alert.Window.InitialFirstResponder = input; + + var response = alert.RunModal(); + if (response == 1000) // NSAlertFirstButtonReturn (OK) + tcs.TrySetResult(input.StringValue); + else + tcs.TrySetResult(null); + }); + return await tcs.Task; #else // Windows implementation using ContentDialog would go here From 42a5dedcc47eaa21b811fb3e355c60be9c512fce Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 19:11:02 -0400 Subject: [PATCH 49/67] Fix cloud keystore deletion silently failing DeleteKeystoreFromCloudAsync was ignoring the bool return values from DeleteSecretAsync, so when Infisical delete calls failed, the UI showed a success toast and optimistically removed the item. On refresh, the keystore reappeared because it was never actually deleted. Now checks return values and throws InvalidOperationException with details on which secrets failed to delete. Also improved Infisical error logging to include inner exception messages for debugging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.Core/Services/InfisicalProvider.cs | 6 ++++-- .../Services/KeystoreSyncService.cs | 15 ++++++++++++--- src/MauiSherpa/Pages/Keystores.razor | 7 ++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/MauiSherpa.Core/Services/InfisicalProvider.cs b/src/MauiSherpa.Core/Services/InfisicalProvider.cs index ad2b2390..2311d387 100644 --- a/src/MauiSherpa.Core/Services/InfisicalProvider.cs +++ b/src/MauiSherpa.Core/Services/InfisicalProvider.cs @@ -223,14 +223,16 @@ public async Task DeleteSecretAsync(string key, CancellationToken cancella _logger.LogInformation($"Deleted secret: {key}"); return true; } - catch (InfisicalException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase)) + catch (InfisicalException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || + ex.InnerException?.Message?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) { _logger.LogInformation($"Secret already deleted or not found: {key}"); return true; } catch (InfisicalException ex) { - _logger.LogError($"Infisical delete secret failed: {ex.Message}", ex); + var innerMsg = ex.InnerException?.Message; + _logger.LogError($"Infisical delete secret failed for '{key}': {ex.Message}{(innerMsg != null ? $" → {innerMsg}" : "")}", ex); return false; } catch (Exception ex) diff --git a/src/MauiSherpa.Core/Services/KeystoreSyncService.cs b/src/MauiSherpa.Core/Services/KeystoreSyncService.cs index 4156c304..41b1e167 100644 --- a/src/MauiSherpa.Core/Services/KeystoreSyncService.cs +++ b/src/MauiSherpa.Core/Services/KeystoreSyncService.cs @@ -175,9 +175,18 @@ public async Task DeleteKeystoreFromCloudAsync(string cloudKey, CancellationToke var alias = ExtractAliasFromKey(cloudKey); _logger.LogInformation($"Deleting keystore from cloud: {alias}"); - await _cloudService.DeleteSecretAsync(GetCloudKey(alias, "JKS"), ct); - await _cloudService.DeleteSecretAsync(GetCloudKey(alias, "PWD"), ct); - await _cloudService.DeleteSecretAsync(GetCloudKey(alias, "META"), ct); + var failures = new List(); + + if (!await _cloudService.DeleteSecretAsync(GetCloudKey(alias, "JKS"), ct)) + failures.Add("keystore file (JKS)"); + if (!await _cloudService.DeleteSecretAsync(GetCloudKey(alias, "PWD"), ct)) + failures.Add("password (PWD)"); + if (!await _cloudService.DeleteSecretAsync(GetCloudKey(alias, "META"), ct)) + failures.Add("metadata (META)"); + + if (failures.Count > 0) + throw new InvalidOperationException( + $"Failed to delete keystore '{alias}' from cloud. Could not remove: {string.Join(", ", failures)}"); } private static string GetCloudKey(string alias, string suffix) => $"{CloudKeyPrefix}{alias}_{suffix}"; diff --git a/src/MauiSherpa/Pages/Keystores.razor b/src/MauiSherpa/Pages/Keystores.razor index 7aaf416b..3e125487 100644 --- a/src/MauiSherpa/Pages/Keystores.razor +++ b/src/MauiSherpa/Pages/Keystores.razor @@ -601,7 +601,12 @@ else { var cloudKey = $"KEYSTORE_{alias}"; await SyncService.DeleteKeystoreFromCloudAsync(cloudKey, default); - await LoadSyncStatuses(); + + // Update local collections directly — cloud provider may lag on ListSecrets after delete + cloudOnlyStatuses.RemoveAll(s => s.Alias == alias); + if (localId != null) + syncStatuses.Remove(localId); + StateHasChanged(); ToastService.ShowSuccess($"Keystore '{alias}' deleted from cloud."); } From b5c9b5b15aa34428499119a44f2b45e2dda3780c Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 19:43:08 -0400 Subject: [PATCH 50/67] Fix cloud keystore delete using wrong alias for names with underscores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtractAliasFromKey strips the last _segment assuming it's the suffix (_JKS/_PWD/_META). But DeleteFromCloud passed 'KEYSTORE_{alias}' with no suffix, so for aliases like TEST_SIGNING_KEY it extracted TEST_SIGNING instead — deleting non-existent keys while the real ones remained. Fix: pass the alias directly to DeleteKeystoreFromCloudAsync instead of wrapping it in KEYSTORE_ prefix and re-extracting. Also filter Infisical ListSecrets to exclude imported secrets from other paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.Core/Interfaces.cs | 2 +- .../Services/InfisicalProvider.cs | 16 ++++++++++++++-- .../Services/KeystoreSyncService.cs | 3 +-- src/MauiSherpa/Pages/Keystores.razor | 3 +-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 4b66203a..89b08583 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -3294,7 +3294,7 @@ public interface IKeystoreSyncService Task UploadKeystorePasswordAsync(string keystoreId, string password, CancellationToken ct = default); Task UploadKeystoreMetadataAsync(string keystoreId, CancellationToken ct = default); Task DownloadKeystoreFromCloudAsync(string cloudKey, CancellationToken ct = default); - Task DeleteKeystoreFromCloudAsync(string cloudKey, CancellationToken ct = default); + Task DeleteKeystoreFromCloudAsync(string alias, CancellationToken ct = default); } public record KeystoreSyncStatus( diff --git a/src/MauiSherpa.Core/Services/InfisicalProvider.cs b/src/MauiSherpa.Core/Services/InfisicalProvider.cs index 2311d387..199bf757 100644 --- a/src/MauiSherpa.Core/Services/InfisicalProvider.cs +++ b/src/MauiSherpa.Core/Services/InfisicalProvider.cs @@ -223,7 +223,8 @@ public async Task DeleteSecretAsync(string key, CancellationToken cancella _logger.LogInformation($"Deleted secret: {key}"); return true; } - catch (InfisicalException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || + catch (InfisicalException ex) when ( + ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || ex.InnerException?.Message?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) { _logger.LogInformation($"Secret already deleted or not found: {key}"); @@ -237,7 +238,7 @@ public async Task DeleteSecretAsync(string key, CancellationToken cancella } catch (Exception ex) { - _logger.LogError($"Infisical delete secret error: {ex.Message}", ex); + _logger.LogError($"Infisical delete secret error for '{key}': {ex.Message}", ex); return false; } } @@ -301,10 +302,21 @@ public async Task> ListSecretsAsync(string? prefix = null, if (secrets == null) return Array.Empty(); + _logger.LogDebug($"Infisical ListSecrets returned {secrets.Length} secrets (path={SecretPath}, env={Environment})"); var sanitizedPrefix = !string.IsNullOrEmpty(prefix) ? SanitizeSecretName(prefix) : null; var result = new List(); foreach (var secret in secrets) { + _logger.LogDebug($" Secret: {secret.SecretKey} path={secret.SecretPath} env={secret.Environment}"); + + // Skip imported secrets from other paths — they can't be deleted from our path + if (!string.IsNullOrEmpty(secret.SecretPath) && + !string.Equals(secret.SecretPath, SecretPath, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug($" → Skipped (path mismatch: '{secret.SecretPath}' != '{SecretPath}')"); + continue; + } + var secretKey = secret.SecretKey; // Filter by sanitized prefix if specified diff --git a/src/MauiSherpa.Core/Services/KeystoreSyncService.cs b/src/MauiSherpa.Core/Services/KeystoreSyncService.cs index 41b1e167..e9e875ac 100644 --- a/src/MauiSherpa.Core/Services/KeystoreSyncService.cs +++ b/src/MauiSherpa.Core/Services/KeystoreSyncService.cs @@ -170,9 +170,8 @@ public async Task DownloadKeystoreFromCloudAsync(string cloudKey, CancellationTo _logger.LogInformation($"Keystore downloaded from cloud: {alias} → {filePath}"); } - public async Task DeleteKeystoreFromCloudAsync(string cloudKey, CancellationToken ct = default) + public async Task DeleteKeystoreFromCloudAsync(string alias, CancellationToken ct = default) { - var alias = ExtractAliasFromKey(cloudKey); _logger.LogInformation($"Deleting keystore from cloud: {alias}"); var failures = new List(); diff --git a/src/MauiSherpa/Pages/Keystores.razor b/src/MauiSherpa/Pages/Keystores.razor index 3e125487..d0c355ce 100644 --- a/src/MauiSherpa/Pages/Keystores.razor +++ b/src/MauiSherpa/Pages/Keystores.razor @@ -599,8 +599,7 @@ else try { - var cloudKey = $"KEYSTORE_{alias}"; - await SyncService.DeleteKeystoreFromCloudAsync(cloudKey, default); + await SyncService.DeleteKeystoreFromCloudAsync(alias, default); // Update local collections directly — cloud provider may lag on ListSecrets after delete cloudOnlyStatuses.RemoveAll(s => s.Alias == alias); From 0db3813173f3c406d3675b5b2f7345db63c9bafb Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 19:53:44 -0400 Subject: [PATCH 51/67] Enable MauiDevFlow profiling in sample app Configure EnableProfiler, EnableHighLevelUiHooks, and EnableDetailedUiHooks in the profiling sample app. Update MauiDevFlow packages from 0.18.0 to 0.20.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.ProfilingSample/MauiProgram.cs | 7 ++++++- .../MauiSherpa.ProfilingSample.csproj | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/MauiSherpa.ProfilingSample/MauiProgram.cs b/src/MauiSherpa.ProfilingSample/MauiProgram.cs index f8368923..81dd53b3 100644 --- a/src/MauiSherpa.ProfilingSample/MauiProgram.cs +++ b/src/MauiSherpa.ProfilingSample/MauiProgram.cs @@ -27,7 +27,12 @@ public static MauiApp CreateMauiApp() #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); builder.Logging.AddDebug(); - builder.AddMauiDevFlowAgent(); + builder.AddMauiDevFlowAgent(options => + { + options.EnableProfiler = true; + options.EnableHighLevelUiHooks = true; + options.EnableDetailedUiHooks = true; + }); builder.AddMauiBlazorDevFlowTools(); #endif diff --git a/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj index c54ada97..ad092c16 100644 --- a/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj +++ b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj @@ -26,8 +26,8 @@ - - + + From e4ac6a3f7b07e4e0cf46cb82620feb4f687ed4ac Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 20:07:20 -0400 Subject: [PATCH 52/67] Require MauiDevFlow 0.20.0 for profiling, show version message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all MauiDevFlow package references from 0.18.0 to 0.20.0. Add minimum version check (0.20.0) to the DevFlow profiling tab — shows a clear message when the connected agent is too old instead of failing with a capabilities error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj | 4 ++-- src/MauiSherpa/MauiSherpa.csproj | 4 ++-- .../Pages/Inspector/DevFlowInspector.razor | 2 +- .../Pages/Inspector/DevFlowProfilingTab.razor | 17 ++++++++++++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj index d132ae80..9e9fb27c 100644 --- a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj +++ b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj @@ -38,8 +38,8 @@ - - + + diff --git a/src/MauiSherpa/MauiSherpa.csproj b/src/MauiSherpa/MauiSherpa.csproj index 1e64f07b..d1b8192b 100644 --- a/src/MauiSherpa/MauiSherpa.csproj +++ b/src/MauiSherpa/MauiSherpa.csproj @@ -53,8 +53,8 @@ - - + + diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor b/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor index d279c75a..09cdd1a2 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor @@ -63,7 +63,7 @@ break; case "profiling": - + break; case "webview": diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor index d1ca9998..36df1614 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor @@ -56,7 +56,16 @@
} - @if (capabilities == null) + @if (!MeetsMinVersion) + { +
+ +
MauiDevFlow agent version @(AgentVersion ?? "unknown") does not support profiling.
+
Minimum required version: @MinRequiredVersion
+
Update the Redth.MauiDevFlow.Agent NuGet package in your app and rebuild.
+
+ } + else if (capabilities == null) {
@@ -346,7 +355,9 @@ @code { [Parameter] public DevFlowAgentClient Client { get; set; } = null!; + [Parameter] public string? AgentVersion { get; set; } + private static readonly Version MinRequiredVersion = new(0, 20, 0); private const int MaxSampleHistory = 600; private const int MaxMarkerHistory = 300; private const int MaxSpanHistory = 600; @@ -374,6 +385,7 @@ private bool CanStart => !isBusy && capabilities?.Available == true && session?.IsActive != true; private bool CanStop => !isBusy && session?.IsActive == true; + private bool MeetsMinVersion => Version.TryParse(AgentVersion, out var v) && v >= MinRequiredVersion; private DevFlowProfilerSample? LatestSample => samples.Count > 0 ? samples[^1] : null; private (int Gc0, int Gc1, int Gc2) gcDelta => GetGcDelta(); private string fpsPoints => GetSparklinePoints(s => s.Fps); @@ -438,6 +450,9 @@ protected override async Task OnInitializedAsync() { + if (!MeetsMinVersion) + return; + await RefreshCapabilitiesAsync(); if (capabilities?.Available == true) { From d600c92de207852cae656ea5673c338a6e935949 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 20:18:53 -0400 Subject: [PATCH 53/67] Fix dotnet-trace to capture useful CPU sampling data for speedscope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dotnet-trace collect was invoked without --profile or --providers, producing nearly empty speedscope files (9 generic frames, no managed stacks). Now maps ProfilingCaptureKind to dotnet-trace profiles: - Cpu/Startup → cpu-sampling (~100Hz kernel sampling) - Rendering/Network/Energy/SystemTrace → dotnet-common Also adds --format Speedscope so dotnet-trace emits both .nettrace and .speedscope.json during capture, eliminating the separate post-capture conversion step. Converter kept as fallback for manually imported .nettrace files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProfilingCaptureOrchestrationService.cs | 27 +++++++++++++++++++ .../Services/ProfilingSessionRunnerService.cs | 12 +++++++++ 2 files changed, 39 insertions(+) diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index d79b3e59..82c86ed0 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -675,6 +675,33 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C arguments.Add("--output"); arguments.Add(traceArtifactPath); + // Map capture kinds to dotnet-trace profiles for meaningful data + var profiles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var kind in definition.CaptureKinds.Where(k => TraceCaptureKinds.Contains(k))) + { + switch (kind) + { + case ProfilingCaptureKind.Cpu: + case ProfilingCaptureKind.Startup: + profiles.Add("cpu-sampling"); + break; + case ProfilingCaptureKind.Rendering: + case ProfilingCaptureKind.Network: + case ProfilingCaptureKind.Energy: + case ProfilingCaptureKind.SystemTrace: + profiles.Add("dotnet-common"); + break; + } + } + if (profiles.Count == 0) + profiles.Add("cpu-sampling"); + arguments.Add("--profile"); + arguments.Add(string.Join(",", profiles)); + + // Emit speedscope JSON alongside the .nettrace during capture + arguments.Add("--format"); + arguments.Add("Speedscope"); + var dependsOn = new List(); if (diagnosticPortAddress is not null) dependsOn.Add("start-dsrouter"); diff --git a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs index 02167154..388320b6 100644 --- a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs +++ b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs @@ -671,6 +671,18 @@ private async Task> ConvertTraceArtifactsAsync( if (!artifact.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) continue; + // dotnet-trace collect --format Speedscope already emits a .speedscope.json + // alongside the .nettrace — skip conversion if it already exists. + var baseName = artifact[..^".nettrace".Length]; + var existingSpeedscope = $"{baseName}.speedscope.json"; + if (File.Exists(existingSpeedscope)) + { + _logger.LogInformation($"Speedscope file already exists from capture: {Path.GetFileName(existingSpeedscope)}"); + if (!result.Contains(existingSpeedscope)) + result.Add(existingSpeedscope); + continue; + } + try { _logger.LogInformation($"Converting {Path.GetFileName(artifact)} to speedscope format..."); From ee56052d4ceb990dde37e2f5461cfba00d9171bc Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 20:21:08 -0400 Subject: [PATCH 54/67] Fix dotnet-trace profile: use dotnet-sampled-thread-time (cross-platform) cpu-sampling is Linux-only (collect-linux). Use dotnet-sampled-thread-time which works on all platforms and samples managed stacks at ~100Hz. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/ProfilingCaptureOrchestrationService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index 82c86ed0..5fd63bb2 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -675,7 +675,9 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C arguments.Add("--output"); arguments.Add(traceArtifactPath); - // Map capture kinds to dotnet-trace profiles for meaningful data + // Map capture kinds to dotnet-trace profiles for meaningful data. + // "dotnet-sampled-thread-time" samples managed stacks at ~100Hz (works on all platforms). + // "cpu-sampling" and "thread-time" are Linux-only (collect-linux). var profiles = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var kind in definition.CaptureKinds.Where(k => TraceCaptureKinds.Contains(k))) { @@ -683,7 +685,7 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C { case ProfilingCaptureKind.Cpu: case ProfilingCaptureKind.Startup: - profiles.Add("cpu-sampling"); + profiles.Add("dotnet-sampled-thread-time"); break; case ProfilingCaptureKind.Rendering: case ProfilingCaptureKind.Network: @@ -694,7 +696,7 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C } } if (profiles.Count == 0) - profiles.Add("cpu-sampling"); + profiles.Add("dotnet-sampled-thread-time"); arguments.Add("--profile"); arguments.Add(string.Join(",", profiles)); From 5942c427a4d9e29f88f4fc7694da226765e25b70 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 20:25:59 -0400 Subject: [PATCH 55/67] Remove --format Speedscope from dotnet-trace collect The --format flag may cause issues with --dsrouter mode, causing the trace to end prematurely. Keep using post-capture conversion via dotnet-trace convert instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/ProfilingCaptureOrchestrationService.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index 5fd63bb2..9e23ad4f 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -700,10 +700,6 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C arguments.Add("--profile"); arguments.Add(string.Join(",", profiles)); - // Emit speedscope JSON alongside the .nettrace during capture - arguments.Add("--format"); - arguments.Add("Speedscope"); - var dependsOn = new List(); if (diagnosticPortAddress is not null) dependsOn.Add("start-dsrouter"); From 5222f2b60d6edaa2274b3751644885b5deeb31c6 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 20:54:26 -0400 Subject: [PATCH 56/67] Make trace on-demand with Start/Stop buttons alongside GC Dump - Remove capture-trace from auto-planned pipeline steps - Add StartTraceAsync/StopTraceAsync to IProfilingSessionRunner - Add IsTraceActive property for mutual exclusion with gcdump - Pipeline enters WaitingForStop for on-demand actions even without ManualStop steps - Always use standalone dsrouter on mobile (needed for on-demand trace/gcdump) - Add JIT/Loader provider flags (Microsoft-Windows-DotNETRuntime:0x10000018:5) for speedscope symbol resolution - Unified capture action bar UI with Start Trace, Stop Trace, and GC Dump buttons - Disable conflicting actions (no gcdump while tracing, no trace while gcdump) - Stop active trace automatically when user clicks Stop Capture - Update 4 orchestration tests for on-demand trace pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.Core/Interfaces.cs | 16 ++ .../ProfilingCaptureOrchestrationService.cs | 26 +-- .../Modals/ProfilingCaptureWizardModal.razor | 144 ++++++++++--- .../Services/ProfilingSessionRunnerService.cs | 203 ++++++++++++++++++ ...ofilingCaptureOrchestrationServiceTests.cs | 47 ++-- 5 files changed, 370 insertions(+), 66 deletions(-) diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 89b08583..57769316 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -328,6 +328,22 @@ public interface IProfilingSessionRunner : IDisposable /// Returns the path to the .gcdump file, or null if collection failed. /// Task CollectGcDumpAsync(CancellationToken ct = default); + + /// + /// Whether a trace capture is currently active. + /// + bool IsTraceActive { get; } + + /// + /// Start an on-demand trace capture. Returns the step ID or null if it cannot start. + /// The trace runs until StopTraceAsync() is called. + /// + string? StartTraceAsync(); + + /// + /// Stop the currently running on-demand trace. + /// + Task StopTraceAsync(); } /// diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs index 9e23ad4f..1694cc58 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs @@ -103,7 +103,9 @@ public async Task PlanCaptureAsync( var hasLogCapture = definition.CaptureKinds.Contains(ProfilingCaptureKind.Logs); var dsrouterPlatformArg = GetDsRouterPlatformArg(definition.Target); var isMobileTarget = dsrouterPlatformArg is not null; - var needsStandaloneDsRouter = isMobileTarget && hasTraceCapture && hasMemoryCapture; + // Both trace and gcdump are now on-demand, so always use standalone dsrouter + // on mobile when either is requested — they need to share the diagnostic port. + var needsStandaloneDsRouter = isMobileTarget && (hasTraceCapture || hasMemoryCapture); // If we need standalone dsrouter, clear the inline arg so capture steps use --diagnostic-port instead if (needsStandaloneDsRouter) @@ -121,7 +123,11 @@ public async Task PlanCaptureAsync( if (hasTraceCapture) { - var (traceStep, traceArtifact) = CreateTraceCaptureStep( + // Don't add traceStep to pipeline — trace is an on-demand action + // triggered by the user via Start Trace / Stop Trace buttons. + // This prevents trace from auto-starting and competing with gcdump + // for the diagnostic port. + var (_, traceArtifact) = CreateTraceCaptureStep( definition, normalizedOptions, dsrouterPlatformArg, @@ -130,17 +136,6 @@ public async Task PlanCaptureAsync( needsStandaloneDsRouter ? diagnostics?.IpcAddress : null, androidSdkPath); - if (isMobileTarget && - normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Launch && - normalizedOptions.SuspendAtStartup) - { - preLaunchCaptureSteps.Add(traceStep); - } - else - { - postLaunchCaptureSteps.Add(traceStep); - } - expectedArtifacts.Add(traceArtifact); } @@ -700,6 +695,11 @@ private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) C arguments.Add("--profile"); arguments.Add(string.Join(",", profiles)); + // Add JIT/Loader provider flags for managed symbol resolution in speedscope. + // 0x10000018 = JitTracing | NGenTracing | Loader keywords, Verbose level (5). + arguments.Add("--providers"); + arguments.Add("Microsoft-Windows-DotNETRuntime:0x10000018:5"); + var dependsOn = new List(); if (diagnosticPortAddress is not null) dependsOn.Add("start-dsrouter"); diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index ad641d69..d0bf5e69 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -520,28 +520,71 @@ }
- @if (pipelineState == ProfilingPipelineState.WaitingForStop && selectedCaptureKinds.Contains(ProfilingCaptureKind.Memory)) + @if (pipelineState == ProfilingPipelineState.WaitingForStop) { -
- - @if (gcDumpCount > 0) - { - @gcDumpCount snapshot@(gcDumpCount != 1 ? "s" : "") collected - } -
+ var hasTraceCapture = selectedCaptureKinds.Any(k => + k is ProfilingCaptureKind.Cpu or ProfilingCaptureKind.Startup + or ProfilingCaptureKind.Network or ProfilingCaptureKind.Rendering + or ProfilingCaptureKind.Energy or ProfilingCaptureKind.SystemTrace); + var hasMemoryCapture = selectedCaptureKinds.Contains(ProfilingCaptureKind.Memory); + + @if (hasTraceCapture || hasMemoryCapture) + { +
+
+ On-demand Actions + (only one tool can use the diagnostic port at a time) +
+
+ @if (hasTraceCapture) + { + @if (PipelineRunner.IsTraceActive) + { + + } + else + { + + } + @if (traceCount > 0) + { + @traceCount trace@(traceCount != 1 ? "s" : "") captured + } + } + + @if (hasMemoryCapture) + { + + @if (gcDumpCount > 0) + { + @gcDumpCount snapshot@(gcDumpCount != 1 ? "s" : "") collected + } + } +
+
+ } } @if (pipelineState == ProfilingPipelineState.Running) @@ -597,6 +640,7 @@ private HashSet expandedCommands = new(); private bool isCollectingGcDump; private int gcDumpCount; + private int traceCount; private string? activeSessionId; @@ -1263,12 +1307,17 @@ private async Task HandleStopCapture() { + // Stop any active trace before stopping the pipeline + if (PipelineRunner.IsTraceActive) + { + await PipelineRunner.StopTraceAsync(); + } await PipelineRunner.StopCaptureAsync(); } private async Task CollectGcDumpOnDemandAsync() { - if (isCollectingGcDump) return; + if (isCollectingGcDump || PipelineRunner.IsTraceActive) return; isCollectingGcDump = true; StateHasChanged(); @@ -1296,6 +1345,27 @@ } } + private void StartTraceOnDemand() + { + if (PipelineRunner.IsTraceActive || isCollectingGcDump) return; + + var stepId = PipelineRunner.StartTraceAsync(); + if (stepId is not null) + { + traceCount++; + StateHasChanged(); + } + } + + private async Task StopTraceOnDemandAsync() + { + if (!PipelineRunner.IsTraceActive) return; + + await PipelineRunner.StopTraceAsync(); + await AlertService.ShowToastAsync($"Trace #{traceCount} captured"); + StateHasChanged(); + } + private void HandleCancelPipeline() { PipelineRunner.Cancel(); @@ -2165,21 +2235,41 @@ gap: 0.4rem; } - .gcdump-action-bar { + .capture-action-bar { + padding: 0.75rem 0; + border-top: 1px solid var(--border-secondary); + } + + .capture-action-bar-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; + } + + .capture-action-hint { + font-weight: 400; + font-size: 0.75rem; + color: var(--text-secondary); + } + + .capture-action-buttons { display: flex; align-items: center; gap: 0.75rem; - padding: 0.75rem 0; - border-top: 1px solid var(--border-secondary); + flex-wrap: wrap; } - .gcdump-action-bar .btn { + .capture-action-buttons .btn { display: flex; align-items: center; gap: 0.35rem; } - .gcdump-count { + .capture-action-count { font-size: 0.8rem; color: var(--text-secondary); } diff --git a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs index 388320b6..1b910df0 100644 --- a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs +++ b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs @@ -18,6 +18,8 @@ public class ProfilingSessionRunnerService : IProfilingSessionRunner private DateTime _startTime; private volatile bool _stopRequested; private int _gcDumpCount; + private int _traceCount; + private TaskCompletionSource _stopTcs = new(); public ProfilingPipelineState State => _state; public IReadOnlyList Steps => _steps; @@ -39,6 +41,8 @@ public async Task RunAsync(ProfilingCapturePlan plan, C _startTime = DateTime.Now; _stopRequested = false; _gcDumpCount = 0; + _traceCount = 0; + _stopTcs = new TaskCompletionSource(); if (!string.IsNullOrWhiteSpace(plan.OutputDirectory)) Directory.CreateDirectory(plan.OutputDirectory); @@ -237,6 +241,14 @@ private async Task ExecutePipelineAsync(IReadOnlyList comm SetPipelineState(ProfilingPipelineState.WaitingForStop); await Task.WhenAll(longRunningTasks.Values); } + else if (longRunningTasks.Count > 0) + { + // No ManualStop steps, but long-running infrastructure steps (dsrouter, build-and-run) + // are still running. Enter WaitingForStop so the user can perform on-demand actions + // (Start Trace, Collect GC Dump) before choosing to stop. + SetPipelineState(ProfilingPipelineState.WaitingForStop); + await _stopTcs.Task; + } // Stop any OnPipelineStop steps foreach (var (id, task) in longRunningTasks.ToList()) @@ -373,6 +385,9 @@ public async Task StopCaptureAsync() _stopRequested = true; _logger.LogInformation("StopCapture requested — sending SIGINT to all running processes"); + // Signal the WaitingForStop await so the pipeline can proceed to Completing + _stopTcs.TrySetResult(); + // Send SIGINT to all running steps. Cancel() sends SIGINT but does NOT // immediately cancel the CTS, so WaitForExitAsync in LaunchStepAsync will // block until the process actually exits and flushes its output files. @@ -571,6 +586,194 @@ private void SetStepState(ProfilingStepStatus status, ProfilingStepState newStat } } + private string? _activeTraceStepId; + private Task? _activeTraceTask; + + /// + /// Whether a trace capture is currently active. + /// + public bool IsTraceActive => _activeTraceStepId is not null + && _steps.Any(s => s.StepId == _activeTraceStepId && s.State == ProfilingStepState.Running); + + /// + /// Start an on-demand trace capture. Returns the step ID or null if it cannot start. + /// The trace runs until StopTraceAsync() is called. + /// + public string? StartTraceAsync() + { + if (_plan is null || _outputDirectory is null || _state != ProfilingPipelineState.WaitingForStop) + { + _logger.LogWarning("Cannot start trace: pipeline is not in WaitingForStop state."); + return null; + } + + if (IsTraceActive) + { + _logger.LogWarning("Cannot start trace: a trace is already running."); + return null; + } + + var traceNumber = Interlocked.Increment(ref _traceCount); + var fileName = traceNumber == 1 ? "trace.nettrace" : $"trace-{traceNumber}.nettrace"; + var outputPath = Path.Combine(_outputDirectory, fileName); + + var arguments = new List { "collect" }; + var diagnostics = _plan.Diagnostics; + var options = _plan.Options; + + if (diagnostics?.IpcAddress is not null) + { + arguments.Add("--diagnostic-port"); + arguments.Add($"{diagnostics.IpcAddress},connect"); + } + else + { + var discoveredPid = _steps + .FirstOrDefault(s => s.StepId == "discover-process-id") + ?.OutputLines + .LastOrDefault(l => !l.IsError) + ?.Text; + + var buildStep = _stepProcesses.GetValueOrDefault("build-and-run"); + var pid = options.ProcessId + ?? (discoveredPid is not null && int.TryParse(discoveredPid.Trim(), out var parsed) ? parsed : (int?)null) + ?? buildStep?.ProcessId; + + if (pid is null) + { + _logger.LogWarning("Cannot start trace: no process ID available."); + Interlocked.Decrement(ref _traceCount); + return null; + } + + arguments.Add("--process-id"); + arguments.Add(pid.ToString()!); + } + + arguments.Add("--output"); + arguments.Add(outputPath); + + // Add profiling profiles based on capture kinds + var profiles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var kind in _plan.Session.CaptureKinds) + { + switch (kind) + { + case ProfilingCaptureKind.Cpu: + case ProfilingCaptureKind.Startup: + profiles.Add("dotnet-sampled-thread-time"); + break; + case ProfilingCaptureKind.Rendering: + case ProfilingCaptureKind.Network: + case ProfilingCaptureKind.Energy: + case ProfilingCaptureKind.SystemTrace: + profiles.Add("dotnet-common"); + break; + } + } + if (profiles.Count == 0) + profiles.Add("dotnet-sampled-thread-time"); + arguments.Add("--profile"); + arguments.Add(string.Join(",", profiles)); + + // Add JIT/Loader provider flags for managed symbol resolution in speedscope. + // 0x10000018 = JitTracing | NGenTracing | Loader keywords, Verbose level (5). + arguments.Add("--providers"); + arguments.Add("Microsoft-Windows-DotNETRuntime:0x10000018:5"); + + var stepId = $"capture-trace-{traceNumber}"; + _activeTraceStepId = stepId; + var step = new ProfilingCommandStep( + Id: stepId, + Kind: ProfilingCommandStepKind.Capture, + DisplayName: traceNumber == 1 ? "Collect trace" : $"Collect trace #{traceNumber}", + Description: "On-demand trace capture.", + Command: "dotnet-trace", + Arguments: arguments, + WorkingDirectory: options.WorkingDirectory, + IsLongRunning: true, + RequiresManualStop: true, + CanRunParallel: false, + StopTrigger: ProfilingStopTrigger.ManualStop, + ReadyOutputPattern: "Process", + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tool"] = "dotnet-trace", + ["output"] = outputPath + }); + + var status = new ProfilingStepStatus + { + StepId = stepId, + DisplayName = step.DisplayName, + Kind = step.Kind, + IsLongRunning = true, + CanRunParallel = false, + StopTrigger = ProfilingStopTrigger.ManualStop + }; + _steps.Add(status); + StepStateChanged?.Invoke(this, new ProfilingStepStateChangedEventArgs + { + StepId = stepId, + OldState = ProfilingStepState.Pending, + NewState = ProfilingStepState.Pending + }); + + _activeTraceTask = Task.Run(async () => + { + try + { + await LaunchStepAsync(step, _cts?.Token ?? CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning($"Trace capture failed: {ex.Message}"); + } + }); + + return stepId; + } + + /// + /// Stop the currently running on-demand trace. + /// + public async Task StopTraceAsync() + { + if (_activeTraceStepId is null) + { + _logger.LogWarning("No active trace to stop."); + return; + } + + var stepId = _activeTraceStepId; + _logger.LogInformation("Stopping on-demand trace capture..."); + + var status = _steps.FirstOrDefault(s => s.StepId == stepId); + if (status is not null) + SetStepState(status, ProfilingStepState.Stopped); + + if (_stepProcesses.TryGetValue(stepId, out var proc)) + { + _logger.LogInformation("Sending cancel signal to process"); + proc.Cancel(); + } + + if (_activeTraceTask is not null) + { + try + { + await _activeTraceTask.WaitAsync(TimeSpan.FromSeconds(30)); + } + catch (TimeoutException) + { + _logger.LogWarning("Trace process did not exit within timeout."); + } + } + + _activeTraceStepId = null; + _activeTraceTask = null; + } + public void Dispose() { _cts?.Cancel(); diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs index 132fdd8f..00387c71 100644 --- a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs @@ -65,15 +65,17 @@ public async Task PlanCaptureAsync_AndroidEmulatorLaunch_UsesDsRouterServerServe // SuspendAtStartup defaults to false, so trace goes post-launch (after build). // Android also gets adb setup-diagnostic-port step before build-and-run. + // Trace is on-demand (not in pipeline commands), but artifact is still expected. plan.Commands.Select(command => command.Id).Should().ContainInOrder( "start-dsrouter", "setup-diagnostic-port", - "build-and-run", - "capture-trace"); + "build-and-run"); plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); + plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); - // GC dump is on-demand, not in the pipeline commands, but still an expected artifact + // Both trace and GC dump are on-demand, not in the pipeline commands, but still expected artifacts plan.ExpectedArtifacts.Should().Contain(a => a.DisplayName == "GC dump"); + plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); // The adb setprop step configures the Mono diagnostic port var setupStep = plan.Commands.Single(command => command.Id == "setup-diagnostic-port"); @@ -87,12 +89,6 @@ public async Task PlanCaptureAsync_AndroidEmulatorLaunch_UsesDsRouterServerServe buildStep.CanRunParallel.Should().BeTrue(); buildStep.StopTrigger.Should().Be(ProfilingStopTrigger.OnPipelineStop); buildStep.Environment.Should().ContainKey("ANDROID_SERIAL"); - - var traceStep = plan.Commands.Single(command => command.Id == "capture-trace"); - traceStep.CommandLine.Should().Contain("--diagnostic-port"); - traceStep.CommandLine.Should().NotContain("--dsrouter"); - traceStep.CanRunParallel.Should().BeTrue(); - traceStep.StopTrigger.Should().Be(ProfilingStopTrigger.ManualStop); } [Fact] @@ -126,19 +122,16 @@ public async Task PlanCaptureAsync_IosPhysicalDeviceLaunch_UsesDsRouterServerCli plan.Diagnostics!.DsRouterMode.Should().Be(ProfilingDsRouterMode.ServerClient); plan.Diagnostics.ListenMode.Should().Be(ProfilingDiagnosticListenMode.Listen); - // SuspendAtStartup defaults to false, so trace goes post-launch (after build) + // Trace is on-demand (not in pipeline commands), but artifact is still expected. plan.Commands.Select(command => command.Id).Should().ContainInOrder( "start-dsrouter", - "build-and-run", - "capture-trace"); + "build-and-run"); plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); + plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); - // GC dump is on-demand, not in the pipeline commands, but still an expected artifact + // Both trace and GC dump are on-demand, not in the pipeline commands, but still expected artifacts plan.ExpectedArtifacts.Should().Contain(a => a.DisplayName == "GC dump"); - - var traceStep = plan.Commands.Single(command => command.Id == "capture-trace"); - traceStep.CommandLine.Should().Contain("--diagnostic-port"); - traceStep.CommandLine.Should().NotContain("--dsrouter"); + plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); var buildStep = plan.Commands.Single(command => command.Id == "build-and-run"); buildStep.CommandLine.Should().Contain("-f net10.0-ios"); @@ -147,7 +140,7 @@ public async Task PlanCaptureAsync_IosPhysicalDeviceLaunch_UsesDsRouterServerCli } [Fact] - public async Task PlanCaptureAsync_AndroidEmulatorTraceOnly_UsesInlineDsRouter() + public async Task PlanCaptureAsync_AndroidEmulatorTraceOnly_UsesDsRouter() { var snapshot = ConnectedDevicesSnapshot.Empty with { @@ -156,7 +149,7 @@ public async Task PlanCaptureAsync_AndroidEmulatorTraceOnly_UsesInlineDsRouter() _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); var service = CreateService(); - // Only CPU trace, no memory — should use inline --dsrouter + // Only CPU trace, no memory — still uses standalone dsrouter for on-demand trace var session = _catalogService.CreateSessionDefinition( new ProfilingTarget( ProfilingTargetPlatform.Android, @@ -171,11 +164,12 @@ public async Task PlanCaptureAsync_AndroidEmulatorTraceOnly_UsesInlineDsRouter() ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); plan.Validation.IsValid.Should().BeTrue(); - plan.Commands.Should().NotContain(command => command.Id == "start-dsrouter"); - - var traceStep = plan.Commands.Single(command => command.Id == "capture-trace"); - traceStep.CommandLine.Should().Contain("--dsrouter android-emu"); - traceStep.CommandLine.Should().NotContain("--diagnostic-port"); + // Even trace-only uses standalone dsrouter since trace is now on-demand + plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); + // Trace is on-demand, not in pipeline commands + plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); + // But trace artifact is expected + plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); } [Fact] @@ -206,8 +200,9 @@ public async Task PlanCaptureAsync_MacCatalystLaunch_AddsRuntimeBindingForProces plan.RuntimeBindings.Should().ContainSingle(binding => binding.Token == "{{PROCESS_ID}}"); plan.Commands.Select(command => command.Id).Should().ContainInOrder( "build-and-run", - "discover-process-id", - "capture-trace"); + "discover-process-id"); + plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); + plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); } [Fact] From 8f7a3d53e01780126932ea7b73c25e25993cfe78 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 21:07:55 -0400 Subject: [PATCH 57/67] Fix session artifacts, speedscope loading, and nettrace View button - Scan output directory for on-demand gcdump/nettrace/speedscope files not in the expected artifact list (memory-1.gcdump, trace-1.nettrace, etc.) - Add fallback directory scan in wizard manifest creation for all artifact types - Fix speedscope iframe to use #localProfilePath=1 hash param so window.speedscope.loadFileFromBase64() API is available - Add polling (up to 5s) for speedscope API initialization with drop fallback - Hide .nettrace View button on macOS (redundant with .speedscope.json; only useful on Windows with PerfView) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Modals/ProfilingCaptureWizardModal.razor | 35 +++++--- src/MauiSherpa/Pages/Profiling.razor | 7 +- src/MauiSherpa/Pages/SpeedscopeViewer.razor | 2 +- .../Services/ProfilingSessionRunnerService.cs | 20 +++++ .../wwwroot/js/speedscopeInterop.js | 80 ++++++++++--------- 5 files changed, 94 insertions(+), 50 deletions(-) diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor index d0bf5e69..518a522f 100644 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor @@ -1244,18 +1244,33 @@ if (Directory.Exists(sessionDir)) { - foreach (var logFile in Directory.GetFiles(sessionDir, "*.log")) + // Scan for files not already in the artifact list (on-demand gcdumps, + // traces, and log files may have numbered names not in pipeline results) + foreach (var pattern in new[] { "*.log", "*.gcdump", "*.nettrace", "*.speedscope.json" }) { - var logName = Path.GetFileName(logFile); - if (manifest.Artifacts.Any(a => a.FileName == logName)) continue; - long? size = null; - try { size = new FileInfo(logFile).Length; } catch { } - manifest.Artifacts.Add(new ProfilingSessionArtifact + foreach (var file in Directory.GetFiles(sessionDir, pattern)) { - FileName = logName, - Kind = ProfilingArtifactKind.Log, - SizeBytes = size - }); + var name = Path.GetFileName(file); + if (manifest.Artifacts.Any(a => a.FileName == name)) continue; + long? size = null; + try { size = new FileInfo(file).Length; } catch { } + + var ext = GetArtifactExtension(name); + var kind = ext switch + { + ".gcdump" => ProfilingArtifactKind.GcDump, + ".nettrace" => ProfilingArtifactKind.Trace, + ".speedscope.json" => ProfilingArtifactKind.Export, + _ => ProfilingArtifactKind.Log + }; + + manifest.Artifacts.Add(new ProfilingSessionArtifact + { + FileName = name, + Kind = kind, + SizeBytes = size + }); + } } } diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index b7971c37..ce5308f4 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -373,9 +373,12 @@ ? Path.Combine(session.DirectoryPath, artifact.FileName) : artifact.FileName; var extension = GetArtifactExtension(artifact.FileName); - var isTraceFile = extension is ".nettrace" or ".speedscope.json"; + var isSpeedscopeFile = extension is ".speedscope.json"; + var isNettraceFile = extension is ".nettrace"; + // On macOS, .nettrace View is redundant (speedscope.json is alongside it). + // Only show View for .nettrace on Windows where PerfView can open it. + var isTraceFile = isSpeedscopeFile || (isNettraceFile && OperatingSystem.IsWindows()); var isGcDump = extension is ".gcdump"; - var hasViewer = isTraceFile || isGcDump;
diff --git a/src/MauiSherpa/Pages/SpeedscopeViewer.razor b/src/MauiSherpa/Pages/SpeedscopeViewer.razor index 0cdfb470..029275d6 100644 --- a/src/MauiSherpa/Pages/SpeedscopeViewer.razor +++ b/src/MauiSherpa/Pages/SpeedscopeViewer.razor @@ -30,7 +30,7 @@
}
diff --git a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs index 1b910df0..30319ca9 100644 --- a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs +++ b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs @@ -461,6 +461,26 @@ private void KillAllProcesses() missing.Add(path); } + // Scan for on-demand artifacts that aren't in the expected list. + // On-demand gcdumps (memory-1.gcdump) and traces (trace-1.nettrace) use numbered + // filenames that don't match the plan's expected artifact names. + if (Directory.Exists(plan.OutputDirectory)) + { + var foundSet = new HashSet(found, StringComparer.OrdinalIgnoreCase); + foreach (var pattern in new[] { "*.gcdump", "*.nettrace", "*.speedscope.json" }) + { + foreach (var file in Directory.GetFiles(plan.OutputDirectory, pattern)) + { + var fullPath = Path.GetFullPath(file); + if (!foundSet.Contains(fullPath)) + { + found.Add(fullPath); + foundSet.Add(fullPath); + } + } + } + } + return (found, missing); } diff --git a/src/MauiSherpa/wwwroot/js/speedscopeInterop.js b/src/MauiSherpa/wwwroot/js/speedscopeInterop.js index b23a2552..f5f8f663 100644 --- a/src/MauiSherpa/wwwroot/js/speedscopeInterop.js +++ b/src/MauiSherpa/wwwroot/js/speedscopeInterop.js @@ -1,7 +1,7 @@ // JS interop for loading speedscope with a profile file // Speedscope is bundled at /speedscope/index.html and loaded in an iframe. -// We inject the file data into the iframe's speedscope instance by simulating -// a file drop using the DataTransfer API. +// When loaded with #localProfilePath=1, speedscope exposes window.speedscope.loadFileFromBase64(). +// We poll for that API since speedscope sets it up asynchronously. export function openSpeedscopeWithFile(iframeId, filePath, fileDataBase64, fileName) { return new Promise((resolve, reject) => { @@ -11,7 +11,6 @@ export function openSpeedscopeWithFile(iframeId, filePath, fileDataBase64, fileN return; } - // Wait for iframe to load, then inject the file const tryInject = () => { try { const iframeWindow = iframe.contentWindow; @@ -20,35 +19,48 @@ export function openSpeedscopeWithFile(iframeId, filePath, fileDataBase64, fileN return; } - // Convert base64 to ArrayBuffer - const binaryString = atob(fileDataBase64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - // Create a File object - const file = new File([bytes], fileName, { type: 'application/octet-stream' }); + // Poll for speedscope's loadFileFromBase64 API. + // When loaded with #localProfilePath=, speedscope sets up this API + // asynchronously via a script tag. We poll until it's available. + let attempts = 0; + const maxAttempts = 20; + const pollInterval = 250; - // Use speedscope's loadFileFromBase64 API if available - if (iframeWindow.speedscope && iframeWindow.speedscope.loadFileFromBase64) { - iframeWindow.speedscope.loadFileFromBase64(fileName, fileDataBase64); - resolve(true); - return; - } + const poll = () => { + attempts++; + if (iframeWindow.speedscope && iframeWindow.speedscope.loadFileFromBase64) { + iframeWindow.speedscope.loadFileFromBase64(fileName, fileDataBase64); + resolve(true); + return; + } - // Fallback: simulate a file drop on the document body - const dataTransfer = new iframeWindow.DataTransfer(); - dataTransfer.items.add(file); + if (attempts < maxAttempts) { + setTimeout(poll, pollInterval); + } else { + // Final fallback: simulate a file drop + try { + const binaryString = atob(fileDataBase64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const file = new File([bytes], fileName, { type: 'application/octet-stream' }); + const dataTransfer = new iframeWindow.DataTransfer(); + dataTransfer.items.add(file); + const dropEvent = new iframeWindow.DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: dataTransfer + }); + iframeWindow.document.body.dispatchEvent(dropEvent); + resolve(true); + } catch (dropErr) { + reject('loadFileFromBase64 API not available and drop fallback failed: ' + dropErr.message); + } + } + }; - const dropEvent = new iframeWindow.DragEvent('drop', { - bubbles: true, - cancelable: true, - dataTransfer: dataTransfer - }); - - iframeWindow.document.body.dispatchEvent(dropEvent); - resolve(true); + poll(); } catch (e) { reject('Failed to inject file into speedscope: ' + e.message); } @@ -56,15 +68,9 @@ export function openSpeedscopeWithFile(iframeId, filePath, fileDataBase64, fileN if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') { // Give speedscope a moment to initialize after DOM ready - setTimeout(tryInject, 500); + setTimeout(tryInject, 300); } else { - iframe.addEventListener('load', () => setTimeout(tryInject, 500), { once: true }); + iframe.addEventListener('load', () => setTimeout(tryInject, 300), { once: true }); } }); } - -// Convert a .nettrace file to speedscope format by reading the pre-converted file -export function readFileAsBase64(filePath) { - // This is handled on the C# side — we can't read local files from JS - return null; -} From 772649a110cf660719220225b007539a530c4eda Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 21:29:41 -0400 Subject: [PATCH 58/67] Fix speedscope and gcdump viewer content loading Speedscope: Replace cross-frame contentWindow API injection (fails in WKWebView) with postMessage. Add message listener script to bundled speedscope/index.html that receives profile data and simulates file drop within iframe's own JS context. Remove broken #localProfilePath=1 hash that injected failing - - - + diff --git a/tests/MauiSherpa.Core.Tests/Services/GcDumpReportServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/GcDumpReportServiceTests.cs index 0aa78fe8..7c7969aa 100644 --- a/tests/MauiSherpa.Core.Tests/Services/GcDumpReportServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/GcDumpReportServiceTests.cs @@ -101,4 +101,47 @@ 00007ffa12345680 5 240 System.Collections.Generic.List`1[[System report.Types[0].TypeName.Should().Contain("Dictionary"); report.Types[1].TypeName.Should().Contain("List"); } + + [Fact] + public void ParseHeapStatOutput_WithModernFormat_ParsesCorrectly() + { + // Modern dotnet-gcdump output uses "Object Bytes Count Type" header + // with comma-formatted numbers and no hex MT prefix + var output = """ + 2,530,744 GC Heap bytes + 38,658 GC Heap objects + + Object Bytes Count Type + 46,376 1 System.Collections.Generic.Dictionary.Entry[] (Bytes > 10K) [Module(0xb400006fe0fb8390)] + 22,096 1 System.Collections.Generic.Dictionary.Entry[] (Bytes > 10K) [Module(0xb400006fe0fb8390)] + 8,224 1 System.Char[] (Bytes > 1K) [Module(0xb400006fe0fb8390)] + 4,016 3 System.String (Bytes > 1K) [Module(0xb400006fe0fb8390)] + 1,072 22 System.Collections.Generic.Dictionary.Entry[] (Bytes > 1K) [Module(0xb400006fe0fb8390)] + """; + + var report = GcDumpReportParser.ParseHeapStatOutput(output); + + report.Should().NotBeNull(); + report!.Types.Should().HaveCount(5); + + // Should be sorted by size descending + report.Types[0].TypeName.Should().StartWith("System.Collections.Generic.Dictionary.Entry 1K)" and "[Module(...)]" should be stripped + report.Types[3].TypeName.Should().NotContain("Bytes"); + report.Types[3].TypeName.Should().NotContain("Module"); + + report.TotalSize.Should().Be(46376 + 22096 + 8224 + 4016 + 1072); + report.TotalCount.Should().Be(1 + 1 + 1 + 3 + 22); + } } From 3e1948f43f738a2559afa5e8f0ee9f6a3f27e1f9 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 10 Mar 2026 21:38:31 -0400 Subject: [PATCH 59/67] Fix speedscope viewer: force hash before bundle init + localStorage fallback BlazorWebView strips #hash from iframe src attributes, so speedscope never saw #localProfilePath and rendered its landing page instead of activating the loadFileFromBase64 API. Fix: add inline script BEFORE speedscope bundle that forces window.location.hash = 'localProfilePath=_'. Also add localStorage polling as a second communication channel alongside postMessage. Use OnAfterRenderAsync instead of @onload (doesn't fire on iframes in Blazor WebView). Send postMessage multiple times at increasing intervals to handle timing variations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa/Pages/SpeedscopeViewer.razor | 16 ++--- .../wwwroot/js/speedscopeInterop.js | 37 +++++----- src/MauiSherpa/wwwroot/speedscope/index.html | 68 ++++++++++++------- 3 files changed, 68 insertions(+), 53 deletions(-) diff --git a/src/MauiSherpa/Pages/SpeedscopeViewer.razor b/src/MauiSherpa/Pages/SpeedscopeViewer.razor index 0cdfb470..d89c5da3 100644 --- a/src/MauiSherpa/Pages/SpeedscopeViewer.razor +++ b/src/MauiSherpa/Pages/SpeedscopeViewer.razor @@ -31,8 +31,7 @@ } + class="viewer-iframe @(loading ? "hidden" : "")">
@@ -54,19 +53,16 @@ } } - private async Task OnIframeLoaded() + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (filePath is null) - { - loading = false; - errorMessage = "No file path specified."; + if (!firstRender || filePath is null) return; - } if (!File.Exists(filePath)) { - loading = false; errorMessage = $"File not found: {filePath}"; + loading = false; + StateHasChanged(); return; } @@ -79,7 +75,7 @@ "import", "./js/speedscopeInterop.js"); await speedscopeModule.InvokeVoidAsync( - "openSpeedscopeWithFile", "speedscope-frame", filePath, base64, fileName); + "openSpeedscopeWithFile", "speedscope-frame", base64, fileName); } catch (Exception ex) { diff --git a/src/MauiSherpa/wwwroot/js/speedscopeInterop.js b/src/MauiSherpa/wwwroot/js/speedscopeInterop.js index 9528c614..d06db426 100644 --- a/src/MauiSherpa/wwwroot/js/speedscopeInterop.js +++ b/src/MauiSherpa/wwwroot/js/speedscopeInterop.js @@ -1,8 +1,9 @@ // JS interop for loading speedscope with a profile file. // Sends the profile data to the speedscope iframe via postMessage. // The iframe's index.html has a message listener that handles the load. +// Sends multiple times to handle timing — the listener deduplicates. -export function openSpeedscopeWithFile(iframeId, filePath, fileDataBase64, fileName) { +export function openSpeedscopeWithFile(iframeId, fileDataBase64, fileName) { return new Promise((resolve, reject) => { const iframe = document.getElementById(iframeId); if (!iframe) { @@ -10,24 +11,26 @@ export function openSpeedscopeWithFile(iframeId, filePath, fileDataBase64, fileN return; } - const sendData = () => { - if (!iframe.contentWindow) { - reject('Cannot access iframe window'); - return; + const msg = { type: 'loadProfile', name: fileName, base64: fileDataBase64 }; + + const trySend = () => { + try { + if (iframe.contentWindow) { + iframe.contentWindow.postMessage(msg, '*'); + } + } catch (e) { + // contentWindow not accessible — will retry } - iframe.contentWindow.postMessage({ - type: 'loadProfile', - name: fileName, - base64: fileDataBase64 - }, '*'); - resolve(true); }; - // Ensure iframe is loaded before sending - if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') { - setTimeout(sendData, 500); - } else { - iframe.addEventListener('load', () => setTimeout(sendData, 500), { once: true }); - } + // Send multiple times at increasing intervals to handle all timing scenarios: + // - iframe not yet loaded + // - speedscope JS not yet initialized + // - message listener not yet registered + const delays = [500, 1000, 1500, 2000, 3000, 5000]; + delays.forEach(d => setTimeout(trySend, d)); + + // Resolve after first send attempt + setTimeout(() => resolve(true), 600); }); } diff --git a/src/MauiSherpa/wwwroot/speedscope/index.html b/src/MauiSherpa/wwwroot/speedscope/index.html index 4df7aa9c..45648462 100644 --- a/src/MauiSherpa/wwwroot/speedscope/index.html +++ b/src/MauiSherpa/wwwroot/speedscope/index.html @@ -11,43 +11,59 @@ + From c081eb7a17e267b947cdbf411d7fea061160405e Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 10:29:21 -0400 Subject: [PATCH 60/67] Show platform-specific native memory labels from MauiDevFlow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NativeMemoryKind to DevFlowProfilerSample DTO to match MauiDevFlow PR #31. Update profiling tab to display platform-aware labels: - apple.phys-footprint → 'Phys Footprint' - android.native-heap-allocated → 'Native Heap' - windows.working-set → 'Working Set' - process.working-set-minus-managed → 'Native Memory' Labels appear on metric card, chart header, and capabilities detail. Raw kind string shown as tooltip and in detail panel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/DevFlow/DevFlowModels.cs | 3 +++ .../Pages/Inspector/DevFlowProfilingTab.razor | 22 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs index 225eab37..fcc1dc3b 100644 --- a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs +++ b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs @@ -158,6 +158,9 @@ public class DevFlowProfilerSample [JsonPropertyName("nativeMemoryBytes")] public long? NativeMemoryBytes { get; set; } + [JsonPropertyName("nativeMemoryKind")] + public string? NativeMemoryKind { get; set; } + [JsonPropertyName("gc0")] public int Gc0 { get; set; } diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor index 36df1614..cc69e9e0 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowProfilingTab.razor @@ -108,8 +108,8 @@
Managed Heap
@FormatBytes(LatestSample?.ManagedBytes)
-
-
Native Heap
+
+
@NativeMemoryLabel
@FormatBytes(LatestSample?.NativeMemoryBytes)
@@ -176,7 +176,7 @@
- Native heap + @NativeMemoryLabel @FormatBytes(LatestSample?.NativeMemoryBytes)
@@ -309,7 +309,11 @@
Frame source: @(LatestSample?.FrameSource ?? "—")
Frame quality: @FormatFrameQuality(LatestSample?.FrameQuality)
Frame quality detail: @(LatestSample?.FrameQuality ?? "—")
-
Native heap: @FormatBytes(LatestSample?.NativeMemoryBytes)
+
@NativeMemoryLabel: @FormatBytes(LatestSample?.NativeMemoryBytes)
+ @if (!string.IsNullOrEmpty(LatestSample?.NativeMemoryKind)) + { +
Memory kind: @LatestSample.NativeMemoryKind
+ }
@@ -387,6 +391,7 @@ private bool CanStop => !isBusy && session?.IsActive == true; private bool MeetsMinVersion => Version.TryParse(AgentVersion, out var v) && v >= MinRequiredVersion; private DevFlowProfilerSample? LatestSample => samples.Count > 0 ? samples[^1] : null; + private string NativeMemoryLabel => FormatNativeMemoryKind(LatestSample?.NativeMemoryKind); private (int Gc0, int Gc1, int Gc2) gcDelta => GetGcDelta(); private string fpsPoints => GetSparklinePoints(s => s.Fps); private string frameP95Points => GetSparklinePoints(s => s.FrameTimeMsP95); @@ -836,6 +841,15 @@ return frameQuality; } + private static string FormatNativeMemoryKind(string? kind) => kind switch + { + "apple.phys-footprint" => "Phys Footprint", + "android.native-heap-allocated" => "Native Heap", + "windows.working-set" => "Working Set", + "process.working-set-minus-managed" => "Native Memory", + _ => "Native Memory", + }; + private static string? BuildSpanDetail(DevFlowProfilerSpan span) { var details = new List(); From d04319a1d0f516f678dc32f75aa29e8574cdc87a Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 12:41:55 -0400 Subject: [PATCH 61/67] Add Platform Features tab to MauiDevFlow inspector - Upgrade MauiDevFlow NuGet packages and CLI to 0.22.0 - Add DTOs for platform info, storage, permissions, sensors, geolocation - Add 22 new API methods to DevFlowAgentClient (platform info, preferences CRUD, secure storage CRUD, permissions, sensors with WebSocket streaming, geolocation) - Add DeleteAsync helper method to DevFlowAgentClient - Create DevFlowPlatformTab.razor with 5 sub-tabs: - Info: card grid showing app info, device, display, battery, connectivity, version tracking - Storage: preferences table with inline edit/add/delete + secure storage lookup with add/delete - Permissions: color-coded status grid for all known permissions - Sensors: start/stop toggles with live WebSocket data streaming - Location: GPS coordinates with accuracy/timeout controls - Register platform tab in DevFlowInspector (ValidTabs, tab button, switch) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/DevFlow/DevFlowModels.cs | 125 ++ .../Services/DevFlowAgentClient.cs | 187 +++ .../MauiSherpa.LinuxGtk.csproj | 4 +- src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj | 4 +- .../MauiSherpa.ProfilingSample.csproj | 4 +- src/MauiSherpa/MauiSherpa.csproj | 4 +- .../Pages/Inspector/DevFlowInspector.razor | 8 +- .../Pages/Inspector/DevFlowPlatformTab.razor | 1344 +++++++++++++++++ 8 files changed, 1671 insertions(+), 9 deletions(-) create mode 100644 src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor diff --git a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs index fcc1dc3b..68b79ce0 100644 --- a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs +++ b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs @@ -601,3 +601,128 @@ public class CdpTarget [JsonPropertyName("ready")] public bool Ready { get; set; } } + +// ── Platform Info DTOs ── + +public class DevFlowAppInfo +{ + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("packageName")] public string? PackageName { get; set; } + [JsonPropertyName("version")] public string? Version { get; set; } + [JsonPropertyName("buildNumber")] public string? BuildNumber { get; set; } + [JsonPropertyName("requestedTheme")] public string? RequestedTheme { get; set; } + [JsonPropertyName("requestedLayoutDirection")] public string? RequestedLayoutDirection { get; set; } +} + +public class DevFlowDeviceInfo +{ + [JsonPropertyName("manufacturer")] public string? Manufacturer { get; set; } + [JsonPropertyName("model")] public string? Model { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("platform")] public string? Platform { get; set; } + [JsonPropertyName("idiom")] public string? Idiom { get; set; } + [JsonPropertyName("deviceType")] public string? DeviceType { get; set; } + [JsonPropertyName("osVersion")] public string? OsVersion { get; set; } +} + +public class DevFlowDisplayInfo +{ + [JsonPropertyName("width")] public double Width { get; set; } + [JsonPropertyName("height")] public double Height { get; set; } + [JsonPropertyName("density")] public double Density { get; set; } + [JsonPropertyName("orientation")] public string? Orientation { get; set; } + [JsonPropertyName("rotation")] public string? Rotation { get; set; } + [JsonPropertyName("refreshRate")] public double RefreshRate { get; set; } +} + +public class DevFlowBatteryInfo +{ + [JsonPropertyName("chargeLevel")] public double ChargeLevel { get; set; } + [JsonPropertyName("state")] public string? State { get; set; } + [JsonPropertyName("powerSource")] public string? PowerSource { get; set; } + [JsonPropertyName("energySaverStatus")] public string? EnergySaverStatus { get; set; } +} + +public class DevFlowConnectivityInfo +{ + [JsonPropertyName("networkAccess")] public string? NetworkAccess { get; set; } + [JsonPropertyName("connectionProfiles")] public List? ConnectionProfiles { get; set; } +} + +public class DevFlowVersionTracking +{ + [JsonPropertyName("currentVersion")] public string? CurrentVersion { get; set; } + [JsonPropertyName("currentBuild")] public string? CurrentBuild { get; set; } + [JsonPropertyName("previousVersion")] public string? PreviousVersion { get; set; } + [JsonPropertyName("previousBuild")] public string? PreviousBuild { get; set; } + [JsonPropertyName("firstInstalledVersion")] public string? FirstInstalledVersion { get; set; } + [JsonPropertyName("firstInstalledBuild")] public string? FirstInstalledBuild { get; set; } + [JsonPropertyName("isFirstLaunchEver")] public bool IsFirstLaunchEver { get; set; } + [JsonPropertyName("isFirstLaunchForCurrentVersion")] public bool IsFirstLaunchForCurrentVersion { get; set; } + [JsonPropertyName("isFirstLaunchForCurrentBuild")] public bool IsFirstLaunchForCurrentBuild { get; set; } + [JsonPropertyName("versionHistory")] public List? VersionHistory { get; set; } + [JsonPropertyName("buildHistory")] public List? BuildHistory { get; set; } +} + +public class DevFlowPermissionStatus +{ + [JsonPropertyName("permission")] public string Permission { get; set; } = string.Empty; + [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; +} + +public class DevFlowGeolocation +{ + [JsonPropertyName("latitude")] public double Latitude { get; set; } + [JsonPropertyName("longitude")] public double Longitude { get; set; } + [JsonPropertyName("altitude")] public double? Altitude { get; set; } + [JsonPropertyName("accuracy")] public double? Accuracy { get; set; } + [JsonPropertyName("speed")] public double? Speed { get; set; } + [JsonPropertyName("course")] public double? Course { get; set; } + [JsonPropertyName("timestamp")] public DateTimeOffset Timestamp { get; set; } + [JsonPropertyName("isFromMockProvider")] public bool IsFromMockProvider { get; set; } +} + +// ── Sensor DTOs ── + +public class DevFlowSensorStatus +{ + [JsonPropertyName("sensor")] public string Sensor { get; set; } = string.Empty; + [JsonPropertyName("active")] public bool Active { get; set; } + [JsonPropertyName("supported")] public bool Supported { get; set; } + [JsonPropertyName("subscribers")] public int Subscribers { get; set; } +} + +public class DevFlowSensorReading +{ + [JsonPropertyName("sensor")] public string Sensor { get; set; } = string.Empty; + [JsonPropertyName("timestamp")] public string? Timestamp { get; set; } + [JsonPropertyName("data")] public JsonElement Data { get; set; } +} + +// ── Storage DTOs ── + +public class DevFlowPreferenceEntry +{ + [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; + [JsonPropertyName("value")] public string? Value { get; set; } + [JsonPropertyName("sharedName")] public string? SharedName { get; set; } +} + +public class DevFlowPreferencesListResponse +{ + [JsonPropertyName("keys")] public List? Keys { get; set; } +} + +public class DevFlowPreferenceSetRequest +{ + [JsonPropertyName("value")] public object? Value { get; set; } + [JsonPropertyName("type")] public string? Type { get; set; } + [JsonPropertyName("sharedName")] public string? SharedName { get; set; } +} + +public class DevFlowSecureStorageEntry +{ + [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; + [JsonPropertyName("value")] public string? Value { get; set; } + [JsonPropertyName("exists")] public bool Exists { get; set; } +} diff --git a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs index c76cd06d..622ba2ad 100644 --- a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs +++ b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs @@ -15,6 +15,8 @@ public class DevFlowAgentClient : IDisposable private CancellationTokenSource? _networkWsCts; private ClientWebSocket? _logsWs; private CancellationTokenSource? _logsWsCts; + private ClientWebSocket? _sensorWs; + private CancellationTokenSource? _sensorWsCts; private bool _disposed; public string AgentHost { get; } @@ -427,6 +429,179 @@ public void StopLogStream() _logsWsCts?.Cancel(); } + // --- Platform Info --- + + public async Task GetAppInfoAsync(CancellationToken ct = default) + => await GetAsync("/api/platform/app-info", ct); + + public async Task GetDeviceInfoAsync(CancellationToken ct = default) + => await GetAsync("/api/platform/device-info", ct); + + public async Task GetDisplayInfoAsync(CancellationToken ct = default) + => await GetAsync("/api/platform/device-display", ct); + + public async Task GetBatteryInfoAsync(CancellationToken ct = default) + => await GetAsync("/api/platform/battery", ct); + + public async Task GetConnectivityAsync(CancellationToken ct = default) + => await GetAsync("/api/platform/connectivity", ct); + + public async Task GetVersionTrackingAsync(CancellationToken ct = default) + => await GetAsync("/api/platform/version-tracking", ct); + + public async Task> GetPermissionsAsync(CancellationToken ct = default) + => await GetAsync>("/api/platform/permissions", ct) ?? new(); + + public async Task CheckPermissionAsync(string permission, CancellationToken ct = default) + => await GetAsync($"/api/platform/permissions/{Uri.EscapeDataString(permission)}", ct); + + public async Task GetGeolocationAsync(string accuracy = "Medium", int timeoutSeconds = 10, CancellationToken ct = default) + => await GetAsync($"/api/platform/geolocation?accuracy={Uri.EscapeDataString(accuracy)}&timeout={timeoutSeconds}", ct); + + // --- Preferences --- + + public async Task> GetPreferencesAsync(string? sharedName = null, CancellationToken ct = default) + { + var query = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; + var result = await GetAsync($"/api/preferences{query}", ct); + return result?.Keys ?? new(); + } + + public async Task GetPreferenceAsync(string key, string type = "string", string? sharedName = null, CancellationToken ct = default) + { + var query = $"?type={Uri.EscapeDataString(type)}"; + if (sharedName != null) query += $"&sharedName={Uri.EscapeDataString(sharedName)}"; + return await GetAsync($"/api/preferences/{Uri.EscapeDataString(key)}{query}", ct); + } + + public async Task SetPreferenceAsync(string key, object? value, string? type = null, string? sharedName = null, CancellationToken ct = default) + { + var body = new DevFlowPreferenceSetRequest { Value = value, Type = type ?? "string", SharedName = sharedName }; + var result = await PostAsync($"/api/preferences/{Uri.EscapeDataString(key)}", body, ct); + return result != null; + } + + public async Task DeletePreferenceAsync(string key, string? sharedName = null, CancellationToken ct = default) + { + var query = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; + return await DeleteAsync($"/api/preferences/{Uri.EscapeDataString(key)}{query}", ct); + } + + public async Task ClearPreferencesAsync(string? sharedName = null, CancellationToken ct = default) + { + var query = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; + try + { + var response = await _http.PostAsync($"{BaseUrl}/api/preferences/clear{query}", null, ct); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + + // --- Secure Storage --- + + public async Task GetSecureStorageAsync(string key, CancellationToken ct = default) + => await GetAsync($"/api/secure-storage/{Uri.EscapeDataString(key)}", ct); + + public async Task SetSecureStorageAsync(string key, string value, CancellationToken ct = default) + { + var body = new { value }; + var result = await PostAsync($"/api/secure-storage/{Uri.EscapeDataString(key)}", body, ct); + return result != null; + } + + public async Task DeleteSecureStorageAsync(string key, CancellationToken ct = default) + => await DeleteAsync($"/api/secure-storage/{Uri.EscapeDataString(key)}", ct); + + public async Task ClearSecureStorageAsync(CancellationToken ct = default) + { + try + { + var response = await _http.PostAsync($"{BaseUrl}/api/secure-storage/clear", null, ct); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + + // --- Sensors --- + + public async Task> GetSensorsAsync(CancellationToken ct = default) + => await GetAsync>("/api/sensors", ct) ?? new(); + + public async Task StartSensorAsync(string sensor, string speed = "UI", CancellationToken ct = default) + { + try + { + var response = await _http.PostAsync($"{BaseUrl}/api/sensors/{Uri.EscapeDataString(sensor)}/start?speed={Uri.EscapeDataString(speed)}", null, ct); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + + public async Task StopSensorAsync(string sensor, CancellationToken ct = default) + { + try + { + var response = await _http.PostAsync($"{BaseUrl}/api/sensors/{Uri.EscapeDataString(sensor)}/stop", null, ct); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + + public async Task StreamSensorAsync(string sensor, Action onReading, string speed = "UI", int throttleMs = 100, CancellationToken ct = default) + { + _sensorWsCts?.Cancel(); + _sensorWsCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var token = _sensorWsCts.Token; + + _sensorWs?.Dispose(); + _sensorWs = new ClientWebSocket(); + + try + { + var wsUrl = $"ws://{AgentHost}:{AgentPort}/ws/sensors?sensor={Uri.EscapeDataString(sensor)}&speed={Uri.EscapeDataString(speed)}&throttleMs={throttleMs}"; + await _sensorWs.ConnectAsync(new Uri(wsUrl), token); + + var buffer = new byte[16 * 1024]; + var sb = new StringBuilder(); + + while (_sensorWs.State == WebSocketState.Open && !token.IsCancellationRequested) + { + var result = await _sensorWs.ReceiveAsync(buffer, token); + if (result.MessageType == WebSocketMessageType.Close) + break; + + sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + if (result.EndOfMessage) + { + var json = sb.ToString(); + sb.Clear(); + try + { + var reading = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (reading != null) onReading(reading); + } + catch { } + } + } + } + catch (OperationCanceledException) { } + catch (WebSocketException) { } + finally + { + if (_sensorWs?.State == WebSocketState.Open) + { + try { await _sensorWs.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); } + catch { } + } + } + } + + public void StopSensorStream() + { + _sensorWsCts?.Cancel(); + } + // --- Helpers --- private async Task GetAsync(string path, CancellationToken ct = default) where T : class @@ -470,6 +645,16 @@ private async Task PostActionAsync(string path, object body, CancellationT catch { return false; } } + private async Task DeleteAsync(string path, CancellationToken ct = default) + { + try + { + var response = await _http.DeleteAsync($"{BaseUrl}{path}", ct); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + public void Dispose() { if (_disposed) return; @@ -478,6 +663,8 @@ public void Dispose() _networkWs?.Dispose(); _logsWsCts?.Cancel(); _logsWs?.Dispose(); + _sensorWsCts?.Cancel(); + _sensorWs?.Dispose(); _http.Dispose(); } } diff --git a/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj b/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj index 3bf191d5..18ad5669 100644 --- a/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj +++ b/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj @@ -39,8 +39,8 @@ - - + + diff --git a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj index 9e9fb27c..aaa3a6d0 100644 --- a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj +++ b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj @@ -38,8 +38,8 @@ - - + + diff --git a/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj index ad092c16..639dbcf9 100644 --- a/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj +++ b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj @@ -26,8 +26,8 @@ - - + + diff --git a/src/MauiSherpa/MauiSherpa.csproj b/src/MauiSherpa/MauiSherpa.csproj index d1b8192b..8a415272 100644 --- a/src/MauiSherpa/MauiSherpa.csproj +++ b/src/MauiSherpa/MauiSherpa.csproj @@ -53,8 +53,8 @@ - - + + diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor b/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor index 09cdd1a2..4768a0e0 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor @@ -38,6 +38,9 @@ + @@ -71,6 +74,9 @@ case "logs": break; + case "platform": + + break; } }
@@ -93,7 +99,7 @@ private string activeTab = "tree"; private static readonly HashSet ValidTabs = new(StringComparer.OrdinalIgnoreCase) { - "tree", "network", "profiling", "webview", "logs" + "tree", "network", "profiling", "webview", "logs", "platform" }; private bool isConnected; private bool isReconnecting; diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor new file mode 100644 index 00000000..57bd140d --- /dev/null +++ b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor @@ -0,0 +1,1344 @@ +@using MauiSherpa.Core.Models.DevFlow +@using MauiSherpa.Core.Services +@using System.Text.Json +@implements IDisposable + +
+
+ + + + + +
+ +
+ @switch (activeSubTab) + { + case "info": + @RenderInfoTab() + break; + case "storage": + @RenderStorageTab() + break; + case "permissions": + @RenderPermissionsTab() + break; + case "sensors": + @RenderSensorsTab() + break; + case "location": + @RenderLocationTab() + break; + } +
+
+ +@code { + [Parameter] public DevFlowAgentClient Client { get; set; } = null!; + [Parameter] public string? AgentVersion { get; set; } + + private string activeSubTab = "info"; + private bool loading; + private string? error; + + // Info state + private DevFlowAppInfo? appInfo; + private DevFlowDeviceInfo? deviceInfo; + private DevFlowDisplayInfo? displayInfo; + private DevFlowBatteryInfo? batteryInfo; + private DevFlowConnectivityInfo? connectivityInfo; + private DevFlowVersionTracking? versionTracking; + private bool infoLoaded; + + // Storage state + private List preferences = new(); + private bool prefsLoaded; + private bool showAddPref; + private string newPrefKey = ""; + private string newPrefValue = ""; + private string newPrefType = "string"; + private string newPrefSharedName = ""; + private string? editingPrefKey; + private string editingPrefValue = ""; + private string secureStorageKey = ""; + private string secureStorageValue = ""; + private DevFlowSecureStorageEntry? secureStorageResult; + private bool showAddSecure; + private string newSecureKey = ""; + private string newSecureValue = ""; + + // Permissions state + private List permissions = new(); + private bool permissionsLoaded; + + // Sensors state + private List sensors = new(); + private bool sensorsLoaded; + private string? streamingSensor; + private DevFlowSensorReading? latestReading; + private string sensorSpeed = "UI"; + private CancellationTokenSource? sensorStreamCts; + + // Location state + private DevFlowGeolocation? location; + private string locationAccuracy = "Medium"; + private int locationTimeout = 10; + private bool locationLoading; + + protected override async Task OnInitializedAsync() + { + await LoadInfoAsync(); + } + + private void SwitchSubTab(string tab) + { + activeSubTab = tab; + _ = tab switch + { + "info" when !infoLoaded => LoadInfoAsync(), + "storage" when !prefsLoaded => LoadPreferencesAsync(), + "permissions" when !permissionsLoaded => LoadPermissionsAsync(), + "sensors" when !sensorsLoaded => LoadSensorsAsync(), + _ => Task.CompletedTask + }; + } + + // ── Info Tab ── + + private async Task LoadInfoAsync() + { + loading = true; + error = null; + StateHasChanged(); + + try + { + var tasks = new Task[] + { + Task.Run(async () => appInfo = await Client.GetAppInfoAsync()), + Task.Run(async () => deviceInfo = await Client.GetDeviceInfoAsync()), + Task.Run(async () => displayInfo = await Client.GetDisplayInfoAsync()), + Task.Run(async () => batteryInfo = await Client.GetBatteryInfoAsync()), + Task.Run(async () => connectivityInfo = await Client.GetConnectivityAsync()), + Task.Run(async () => versionTracking = await Client.GetVersionTrackingAsync()) + }; + await Task.WhenAll(tasks); + infoLoaded = true; + } + catch (Exception ex) { error = ex.Message; } + finally { loading = false; StateHasChanged(); } + } + + private RenderFragment RenderInfoTab() => __builder => + { +
+ +
+ + @if (error != null) + { +
@error
+ } + + @if (!infoLoaded && loading) + { +
Loading platform info...
+ } + else + { +
+ @RenderInfoCard("App Info", "fas fa-mobile-alt", new Dictionary + { + ["Name"] = appInfo?.Name, + ["Package"] = appInfo?.PackageName, + ["Version"] = appInfo?.Version, + ["Build"] = appInfo?.BuildNumber, + ["Theme"] = appInfo?.RequestedTheme, + ["Layout Direction"] = appInfo?.RequestedLayoutDirection + }) + + @RenderInfoCard("Device", "fas fa-microchip", new Dictionary + { + ["Manufacturer"] = deviceInfo?.Manufacturer, + ["Model"] = deviceInfo?.Model, + ["Name"] = deviceInfo?.Name, + ["Platform"] = deviceInfo?.Platform, + ["Idiom"] = deviceInfo?.Idiom, + ["Type"] = deviceInfo?.DeviceType, + ["OS Version"] = deviceInfo?.OsVersion + }) + + @RenderInfoCard("Display", "fas fa-desktop", new Dictionary + { + ["Resolution"] = displayInfo != null ? $"{displayInfo.Width} × {displayInfo.Height}" : null, + ["Density"] = displayInfo?.Density.ToString("F2"), + ["Orientation"] = displayInfo?.Orientation, + ["Rotation"] = displayInfo?.Rotation, + ["Refresh Rate"] = displayInfo?.RefreshRate > 0 ? $"{displayInfo.RefreshRate:F0} Hz" : null + }) + + @RenderInfoCard("Battery", "fas fa-battery-half", new Dictionary + { + ["Charge"] = batteryInfo != null ? $"{batteryInfo.ChargeLevel * 100:F0}%" : null, + ["State"] = batteryInfo?.State, + ["Power Source"] = batteryInfo?.PowerSource, + ["Energy Saver"] = batteryInfo?.EnergySaverStatus + }) + + @RenderInfoCard("Connectivity", "fas fa-wifi", new Dictionary + { + ["Network Access"] = connectivityInfo?.NetworkAccess, + ["Profiles"] = connectivityInfo?.ConnectionProfiles != null ? string.Join(", ", connectivityInfo.ConnectionProfiles) : null + }) + + @RenderInfoCard("Version Tracking", "fas fa-code-branch", new Dictionary + { + ["Current"] = versionTracking != null ? $"{versionTracking.CurrentVersion} ({versionTracking.CurrentBuild})" : null, + ["Previous"] = versionTracking?.PreviousVersion != null ? $"{versionTracking.PreviousVersion} ({versionTracking.PreviousBuild})" : null, + ["First Installed"] = versionTracking?.FirstInstalledVersion != null ? $"{versionTracking.FirstInstalledVersion} ({versionTracking.FirstInstalledBuild})" : null, + ["First Launch Ever"] = versionTracking?.IsFirstLaunchEver.ToString(), + ["First Launch Version"] = versionTracking?.IsFirstLaunchForCurrentVersion.ToString(), + ["First Launch Build"] = versionTracking?.IsFirstLaunchForCurrentBuild.ToString() + }) +
+ } + }; + + private RenderFragment RenderInfoCard(string title, string icon, Dictionary fields) => __builder => + { +
+
+ @title +
+
+ @foreach (var (key, value) in fields) + { +
+ @key + @(value ?? "—") +
+ } +
+
+ }; + + // ── Storage Tab ── + + private async Task LoadPreferencesAsync() + { + loading = true; + StateHasChanged(); + try + { + preferences = await Client.GetPreferencesAsync(); + prefsLoaded = true; + } + catch (Exception ex) { error = ex.Message; } + finally { loading = false; StateHasChanged(); } + } + + private async Task AddPreferenceAsync() + { + if (string.IsNullOrWhiteSpace(newPrefKey)) return; + var sharedName = string.IsNullOrWhiteSpace(newPrefSharedName) ? null : newPrefSharedName; + await Client.SetPreferenceAsync(newPrefKey, newPrefValue, newPrefType, sharedName); + newPrefKey = ""; + newPrefValue = ""; + newPrefType = "string"; + newPrefSharedName = ""; + showAddPref = false; + await LoadPreferencesAsync(); + } + + private async Task DeletePreferenceAsync(string key, string? sharedName) + { + await Client.DeletePreferenceAsync(key, sharedName); + await LoadPreferencesAsync(); + } + + private void StartEditPref(string key, string? value) + { + editingPrefKey = key; + editingPrefValue = value ?? ""; + } + + private async Task SaveEditPrefAsync(string key, string? sharedName) + { + await Client.SetPreferenceAsync(key, editingPrefValue, "string", sharedName); + editingPrefKey = null; + await LoadPreferencesAsync(); + } + + private async Task ClearAllPreferencesAsync() + { + await Client.ClearPreferencesAsync(); + await LoadPreferencesAsync(); + } + + private async Task LookupSecureStorageAsync() + { + if (string.IsNullOrWhiteSpace(secureStorageKey)) return; + secureStorageResult = await Client.GetSecureStorageAsync(secureStorageKey); + StateHasChanged(); + } + + private async Task AddSecureStorageAsync() + { + if (string.IsNullOrWhiteSpace(newSecureKey)) return; + await Client.SetSecureStorageAsync(newSecureKey, newSecureValue); + newSecureKey = ""; + newSecureValue = ""; + showAddSecure = false; + StateHasChanged(); + } + + private async Task DeleteSecureStorageAsync(string key) + { + await Client.DeleteSecureStorageAsync(key); + secureStorageResult = null; + StateHasChanged(); + } + + private async Task ClearAllSecureStorageAsync() + { + await Client.ClearSecureStorageAsync(); + secureStorageResult = null; + StateHasChanged(); + } + + private RenderFragment RenderStorageTab() => __builder => + { +
+
+
+ Preferences +
+ + + +
+
+ + @if (showAddPref) + { +
+ + + + + + +
+ } + + @if (!prefsLoaded && loading) + { +
Loading...
+ } + else if (preferences.Count == 0) + { +
No preferences found. Add one or use the app to create preferences.
+ } + else + { +
+ + + + + + + + + + + @foreach (var pref in preferences) + { + + + + + + + } + +
KeyValueShared Name
@pref.Key + @if (editingPrefKey == pref.Key) + { +
+ + + +
+ } + else + { + @(pref.Value ?? "null") + } +
@(pref.SharedName ?? "—") + +
+
+ } +
+ +
+
+ Secure Storage +
+ + +
+
+ + @if (showAddSecure) + { +
+ + + + +
+ } + +
+ + +
+ + @if (secureStorageResult != null) + { +
+
+ Key + @secureStorageResult.Key +
+
+ Exists + + @if (secureStorageResult.Exists) + { + Yes + } + else + { + No + } + +
+ @if (secureStorageResult.Exists) + { +
+ Value + @secureStorageResult.Value +
+
+ +
+ } +
+ } + else + { +
+ Secure Storage doesn't support listing keys. Enter a key above to look up its value. +
+ } +
+
+ }; + + // ── Permissions Tab ── + + private async Task LoadPermissionsAsync() + { + loading = true; + StateHasChanged(); + try + { + permissions = await Client.GetPermissionsAsync(); + permissionsLoaded = true; + } + catch (Exception ex) { error = ex.Message; } + finally { loading = false; StateHasChanged(); } + } + + private async Task RefreshPermissionAsync(string name) + { + var result = await Client.CheckPermissionAsync(name); + if (result != null) + { + var idx = permissions.FindIndex(p => p.Permission == name); + if (idx >= 0) permissions[idx] = result; + StateHasChanged(); + } + } + + private string GetPermissionStatusClass(string status) => status.ToLowerInvariant() switch + { + "granted" => "status-granted", + "denied" => "status-denied", + "restricted" => "status-restricted", + "disabled" => "status-denied", + "unavailable" => "status-unavailable", + _ => "status-unknown" + }; + + private string GetPermissionIcon(string status) => status.ToLowerInvariant() switch + { + "granted" => "fa-check-circle", + "denied" => "fa-times-circle", + "restricted" => "fa-exclamation-triangle", + "disabled" => "fa-ban", + "unavailable" => "fa-minus-circle", + _ => "fa-question-circle" + }; + + private RenderFragment RenderPermissionsTab() => __builder => + { +
+ +
+ + @if (!permissionsLoaded && loading) + { +
Checking permissions...
+ } + else if (permissions.Count == 0) + { +
No permission data available.
+ } + else + { +
+ @foreach (var perm in permissions) + { +
+ + @FormatPermissionName(perm.Permission) + @perm.Status +
+ } +
+ } + }; + + private string FormatPermissionName(string name) + { + // Convert camelCase or PascalCase to spaced words + var result = new System.Text.StringBuilder(); + foreach (var c in name) + { + if (char.IsUpper(c) && result.Length > 0) result.Append(' '); + result.Append(c); + } + return result.ToString(); + } + + // ── Sensors Tab ── + + private async Task LoadSensorsAsync() + { + loading = true; + StateHasChanged(); + try + { + sensors = await Client.GetSensorsAsync(); + sensorsLoaded = true; + } + catch (Exception ex) { error = ex.Message; } + finally { loading = false; StateHasChanged(); } + } + + private async Task ToggleSensorAsync(string sensorName, bool currentlyActive) + { + if (currentlyActive) + { + await Client.StopSensorAsync(sensorName); + if (streamingSensor == sensorName) + { + StopSensorStreaming(); + } + } + else + { + await Client.StartSensorAsync(sensorName, sensorSpeed); + } + await LoadSensorsAsync(); + } + + private void StartSensorStreaming(string sensorName) + { + StopSensorStreaming(); + streamingSensor = sensorName; + latestReading = null; + sensorStreamCts = new CancellationTokenSource(); + + _ = Task.Run(async () => + { + await Client.StreamSensorAsync(sensorName, reading => + { + latestReading = reading; + InvokeAsync(StateHasChanged); + }, sensorSpeed, 100, sensorStreamCts.Token); + }); + } + + private void StopSensorStreaming() + { + sensorStreamCts?.Cancel(); + sensorStreamCts = null; + Client.StopSensorStream(); + streamingSensor = null; + latestReading = null; + } + + private string GetSensorIcon(string sensor) => sensor.ToLowerInvariant() switch + { + "accelerometer" => "fa-arrows-alt", + "barometer" => "fa-tachometer-alt", + "compass" => "fa-compass", + "gyroscope" => "fa-sync", + "magnetometer" => "fa-magnet", + "orientation" => "fa-cube", + _ => "fa-wave-square" + }; + + private RenderFragment RenderSensorReadingData(DevFlowSensorReading reading) => __builder => + { + var sensor = reading.Sensor.ToLowerInvariant(); + try + { + switch (sensor) + { + case "accelerometer": + case "gyroscope": + case "magnetometer": + if (reading.Data.TryGetProperty("x", out var ax)) + { +
+
X @ax.GetDouble().ToString("F4")
+
Y @reading.Data.GetProperty("y").GetDouble().ToString("F4")
+
Z @reading.Data.GetProperty("z").GetDouble().ToString("F4")
+
+ } + break; + case "barometer": + if (reading.Data.TryGetProperty("pressure", out var pressure)) + { +
+
Pressure @pressure.GetDouble().ToString("F2") hPa
+
+ } + break; + case "compass": + if (reading.Data.TryGetProperty("heading", out var heading)) + { +
+
Heading @heading.GetDouble().ToString("F1")°
+
+ } + break; + case "orientation": + if (reading.Data.TryGetProperty("x", out var ox)) + { +
+
X @ox.GetDouble().ToString("F4")
+
Y @reading.Data.GetProperty("y").GetDouble().ToString("F4")
+
Z @reading.Data.GetProperty("z").GetDouble().ToString("F4")
+
W @reading.Data.GetProperty("w").GetDouble().ToString("F4")
+
+ } + break; + default: +
@reading.Data.ToString()
+ break; + } + } + catch + { +
@reading.Data.ToString()
+ } + }; + + private RenderFragment RenderSensorsTab() => __builder => + { +
+ + +
+ + @if (!sensorsLoaded && loading) + { +
Loading sensors...
+ } + else + { +
+ @foreach (var sensor in sensors) + { +
+
+ +
+ @sensor.Sensor + + @if (!sensor.Supported) + { + Not Supported + } + else if (sensor.Active) + { + Active + @if (sensor.Subscribers > 0) + { + @sensor.Subscribers subscriber(s) + } + } + else + { + Stopped + } + +
+
+
+ @if (sensor.Supported) + { + + @if (sensor.Active) + { + @if (streamingSensor == sensor.Sensor) + { + + } + else + { + + } + } + } +
+
+ + @if (streamingSensor == sensor.Sensor && latestReading != null) + { +
+
+ + Live: @latestReading.Sensor + @latestReading.Timestamp +
+ @RenderSensorReadingData(latestReading) +
+ } + } +
+ } + }; + + // ── Location Tab ── + + private async Task GetLocationAsync() + { + locationLoading = true; + error = null; + StateHasChanged(); + try + { + location = await Client.GetGeolocationAsync(locationAccuracy, locationTimeout); + } + catch (Exception ex) { error = ex.Message; } + finally { locationLoading = false; StateHasChanged(); } + } + + private RenderFragment RenderLocationTab() => __builder => + { +
+ +
+ + s +
+ +
+ + @if (error != null) + { +
@error
+ } + + @if (locationLoading) + { +
Getting location...
+ } + else if (location != null) + { +
+ @RenderInfoCard("Coordinates", "fas fa-crosshairs", new Dictionary + { + ["Latitude"] = location.Latitude.ToString("F6"), + ["Longitude"] = location.Longitude.ToString("F6"), + ["Altitude"] = location.Altitude?.ToString("F1"), + ["Accuracy"] = location.Accuracy?.ToString("F1"), + }) + + @RenderInfoCard("Movement", "fas fa-tachometer-alt", new Dictionary + { + ["Speed"] = location.Speed?.ToString("F1"), + ["Course"] = location.Course?.ToString("F1"), + ["Timestamp"] = location.Timestamp.ToString("G"), + ["Mock Provider"] = location.IsFromMockProvider ? "⚠️ Yes" : "No" + }) +
+ } + else + { +
Click "Get Location" to request the device's current position.
+ } + }; + + public void Dispose() + { + StopSensorStreaming(); + } +} + + From c3e6702d3a366939a70593d7c048e70a9f395165 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 13:01:26 -0400 Subject: [PATCH 62/67] Fix platform tab: permissions parsing, error display, battery N/A - Fix permissions: API returns { permissions: [...] } wrapper, not bare array. Added DevFlowPermissionsResponse wrapper DTO. - Fix info cards: show per-card error messages when API returns errors (e.g., UIKit thread errors for App Info/Display on iOS simulator) - Fix battery: show 'N/A' instead of '-100%' when chargeLevel is -1 - Add .info-card-error CSS with red border for failed cards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/DevFlow/DevFlowModels.cs | 5 + .../Services/DevFlowAgentClient.cs | 5 +- .../Pages/Inspector/DevFlowPlatformTab.razor | 92 +++++++++++++++---- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs index 68b79ce0..70604f54 100644 --- a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs +++ b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs @@ -670,6 +670,11 @@ public class DevFlowPermissionStatus [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; } +public class DevFlowPermissionsResponse +{ + [JsonPropertyName("permissions")] public List? Permissions { get; set; } +} + public class DevFlowGeolocation { [JsonPropertyName("latitude")] public double Latitude { get; set; } diff --git a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs index 622ba2ad..631f68b0 100644 --- a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs +++ b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs @@ -450,7 +450,10 @@ public void StopLogStream() => await GetAsync("/api/platform/version-tracking", ct); public async Task> GetPermissionsAsync(CancellationToken ct = default) - => await GetAsync>("/api/platform/permissions", ct) ?? new(); + { + var result = await GetAsync("/api/platform/permissions", ct); + return result?.Permissions ?? new(); + } public async Task CheckPermissionAsync(string permission, CancellationToken ct = default) => await GetAsync($"/api/platform/permissions/{Uri.EscapeDataString(permission)}", ct); diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor index 57bd140d..892661af 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor @@ -60,6 +60,7 @@ private DevFlowConnectivityInfo? connectivityInfo; private DevFlowVersionTracking? versionTracking; private bool infoLoaded; + private Dictionary cardErrors = new(); // Storage state private List preferences = new(); @@ -120,26 +121,56 @@ { loading = true; error = null; + cardErrors.Clear(); StateHasChanged(); try { - var tasks = new Task[] - { - Task.Run(async () => appInfo = await Client.GetAppInfoAsync()), - Task.Run(async () => deviceInfo = await Client.GetDeviceInfoAsync()), - Task.Run(async () => displayInfo = await Client.GetDisplayInfoAsync()), - Task.Run(async () => batteryInfo = await Client.GetBatteryInfoAsync()), - Task.Run(async () => connectivityInfo = await Client.GetConnectivityAsync()), - Task.Run(async () => versionTracking = await Client.GetVersionTrackingAsync()) - }; - await Task.WhenAll(tasks); + await Task.WhenAll( + LoadInfoCardAsync("App Info", "/api/platform/app-info", r => appInfo = r), + LoadInfoCardAsync("Device", "/api/platform/device-info", r => deviceInfo = r), + LoadInfoCardAsync("Display", "/api/platform/device-display", r => displayInfo = r), + LoadInfoCardAsync("Battery", "/api/platform/battery", r => batteryInfo = r), + LoadInfoCardAsync("Connectivity", "/api/platform/connectivity", r => connectivityInfo = r), + LoadInfoCardAsync("Version Tracking", "/api/platform/version-tracking", r => versionTracking = r) + ); infoLoaded = true; } catch (Exception ex) { error = ex.Message; } finally { loading = false; StateHasChanged(); } } + private async Task LoadInfoCardAsync(string cardName, string endpoint, Action setter) where T : class + { + try + { + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var response = await httpClient.GetStringAsync($"{Client.BaseUrl}{endpoint}"); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + // Check if the response is an error ({"success": false, "error": "..."}) + try + { + var errorCheck = JsonSerializer.Deserialize(response, options); + if (errorCheck.TryGetProperty("success", out var successProp) && !successProp.GetBoolean()) + { + var errorMsg = errorCheck.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error"; + cardErrors[cardName] = errorMsg ?? "Unknown error"; + setter(null); + return; + } + } + catch { /* Not an error response, proceed with deserialization */ } + + setter(JsonSerializer.Deserialize(response, options)); + } + catch (Exception ex) + { + cardErrors[cardName] = ex.Message; + setter(null); + } + } + private RenderFragment RenderInfoTab() => __builder => {
@@ -192,7 +223,7 @@ @RenderInfoCard("Battery", "fas fa-battery-half", new Dictionary { - ["Charge"] = batteryInfo != null ? $"{batteryInfo.ChargeLevel * 100:F0}%" : null, + ["Charge"] = batteryInfo != null ? (batteryInfo.ChargeLevel >= 0 ? $"{batteryInfo.ChargeLevel * 100:F0}%" : "N/A") : null, ["State"] = batteryInfo?.State, ["Power Source"] = batteryInfo?.PowerSource, ["Energy Saver"] = batteryInfo?.EnergySaverStatus @@ -219,18 +250,28 @@ private RenderFragment RenderInfoCard(string title, string icon, Dictionary fields) => __builder => { -
+
@title
- @foreach (var (key, value) in fields) + @if (cardErrors.TryGetValue(title, out var cardError)) { -
- @key - @(value ?? "—") +
+ + @cardError
} + else + { + @foreach (var (key, value) in fields) + { +
+ @key + @(value ?? "—") +
+ } + }
}; @@ -995,6 +1036,25 @@ padding: 0.5rem 0.75rem; } + .info-card-error { + border-color: rgba(239, 68, 68, 0.3); + } + + .card-error-message { + color: #ef4444; + font-size: 0.75rem; + padding: 0.5rem 0; + display: flex; + align-items: flex-start; + gap: 0.4rem; + line-height: 1.4; + } + + .card-error-message i { + flex-shrink: 0; + margin-top: 0.1rem; + } + .info-row { display: flex; justify-content: space-between; From 2f52f5031c8e1d6d5cfcf187725b786f940644b5 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 14:02:44 -0400 Subject: [PATCH 63/67] Upgrade MauiDevFlow NuGet packages to 0.23.0 Includes fix for UIKit thread errors in AppInfo and DeviceDisplay platform handlers (MauiDevFlow PR #35). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj | 4 ++-- src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj | 4 ++-- .../MauiSherpa.ProfilingSample.csproj | 4 ++-- src/MauiSherpa/MauiSherpa.csproj | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj b/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj index 18ad5669..174f6a7d 100644 --- a/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj +++ b/src/MauiSherpa.LinuxGtk/MauiSherpa.LinuxGtk.csproj @@ -39,8 +39,8 @@ - - + + diff --git a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj index aaa3a6d0..21ec64c3 100644 --- a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj +++ b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj @@ -38,8 +38,8 @@ - - + + diff --git a/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj index 639dbcf9..fcaa66be 100644 --- a/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj +++ b/src/MauiSherpa.ProfilingSample/MauiSherpa.ProfilingSample.csproj @@ -26,8 +26,8 @@ - - + + diff --git a/src/MauiSherpa/MauiSherpa.csproj b/src/MauiSherpa/MauiSherpa.csproj index 8a415272..fbea19ba 100644 --- a/src/MauiSherpa/MauiSherpa.csproj +++ b/src/MauiSherpa/MauiSherpa.csproj @@ -53,8 +53,8 @@ - - + + From 4b862f171e79b3617324da36483f6e8612d7d6d2 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 14:36:12 -0400 Subject: [PATCH 64/67] Fix sensor reading display: match actual property names from agent Agent broadcasts PascalCase properties (X, Y, Z, PressureInHectopascals, HeadingMagneticNorth) but UI was looking for lowercase (x, y, z, pressure, heading). Added TryGetDataProperty helper that tries exact, PascalCase, and camelCase. Also shows raw JSON as fallback when properties don't match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/Inspector/DevFlowPlatformTab.razor | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor index 892661af..86581340 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor @@ -688,6 +688,23 @@ _ => "fa-wave-square" }; + private bool TryGetDataProperty(DevFlowSensorReading reading, string name, out JsonElement value) + { + // Try exact name, then PascalCase, then camelCase + if (reading.Data.TryGetProperty(name, out value)) return true; + var pascal = char.ToUpperInvariant(name[0]) + name[1..]; + if (reading.Data.TryGetProperty(pascal, out value)) return true; + var camel = char.ToLowerInvariant(name[0]) + name[1..]; + if (reading.Data.TryGetProperty(camel, out value)) return true; + return false; + } + + private double GetDataDouble(DevFlowSensorReading reading, string name) + { + if (TryGetDataProperty(reading, name, out var val)) return val.GetDouble(); + return 0; + } + private RenderFragment RenderSensorReadingData(DevFlowSensorReading reading) => __builder => { var sensor = reading.Sensor.ToLowerInvariant(); @@ -698,41 +715,57 @@ case "accelerometer": case "gyroscope": case "magnetometer": - if (reading.Data.TryGetProperty("x", out var ax)) + if (TryGetDataProperty(reading, "x", out var ax)) {
X @ax.GetDouble().ToString("F4")
-
Y @reading.Data.GetProperty("y").GetDouble().ToString("F4")
-
Z @reading.Data.GetProperty("z").GetDouble().ToString("F4")
+
Y @GetDataDouble(reading, "y").ToString("F4")
+
Z @GetDataDouble(reading, "z").ToString("F4")
} + else + { +
@reading.Data.ToString()
+ } break; case "barometer": - if (reading.Data.TryGetProperty("pressure", out var pressure)) + if (TryGetDataProperty(reading, "pressureInHectopascals", out var pressure)) {
Pressure @pressure.GetDouble().ToString("F2") hPa
} + else + { +
@reading.Data.ToString()
+ } break; case "compass": - if (reading.Data.TryGetProperty("heading", out var heading)) + if (TryGetDataProperty(reading, "headingMagneticNorth", out var heading)) {
Heading @heading.GetDouble().ToString("F1")°
} + else + { +
@reading.Data.ToString()
+ } break; case "orientation": - if (reading.Data.TryGetProperty("x", out var ox)) + if (TryGetDataProperty(reading, "x", out var ox)) {
X @ox.GetDouble().ToString("F4")
-
Y @reading.Data.GetProperty("y").GetDouble().ToString("F4")
-
Z @reading.Data.GetProperty("z").GetDouble().ToString("F4")
-
W @reading.Data.GetProperty("w").GetDouble().ToString("F4")
+
Y @GetDataDouble(reading, "y").ToString("F4")
+
Z @GetDataDouble(reading, "z").ToString("F4")
+
W @GetDataDouble(reading, "w").ToString("F4")
} + else + { +
@reading.Data.ToString()
+ } break; default:
@reading.Data.ToString()
From 9dcbbaba464e75f2fc814d5fa470d5aef581cf03 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 14:37:58 -0400 Subject: [PATCH 65/67] Improve platform tab error handling: user-friendly messages and warning style - Use HttpClient.GetAsync instead of GetStringAsync to read error response bodies - Parse structured error JSON ({success: false, error: '...'}) from non-2xx responses - Format permission errors as actionable messages ('Missing Android permission: ...') - Show permission errors as amber warnings (shield icon) instead of red errors - Add info-card-warning CSS with amber border and text color - Filed MauiDevFlow issue #37 for structured error reason codes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/Inspector/DevFlowPlatformTab.razor | 75 +++++++++++++++---- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor index 86581340..12ee48b6 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor @@ -145,32 +145,64 @@ try { using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; - var response = await httpClient.GetStringAsync($"{Client.BaseUrl}{endpoint}"); + var httpResponse = await httpClient.GetAsync($"{Client.BaseUrl}{endpoint}"); + var body = await httpResponse.Content.ReadAsStringAsync(); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - // Check if the response is an error ({"success": false, "error": "..."}) - try + // Check if the response is an error (non-2xx or {"success": false, "error": "..."}) + if (!httpResponse.IsSuccessStatusCode || body.Contains("\"success\":false", StringComparison.OrdinalIgnoreCase)) { - var errorCheck = JsonSerializer.Deserialize(response, options); - if (errorCheck.TryGetProperty("success", out var successProp) && !successProp.GetBoolean()) + try { - var errorMsg = errorCheck.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error"; - cardErrors[cardName] = errorMsg ?? "Unknown error"; - setter(null); - return; + var errorCheck = JsonSerializer.Deserialize(body, options); + if (errorCheck.TryGetProperty("error", out var errProp)) + { + cardErrors[cardName] = FormatApiError(errProp.GetString()); + setter(null); + return; + } } + catch { } + + cardErrors[cardName] = $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}"; + setter(null); + return; } - catch { /* Not an error response, proceed with deserialization */ } - setter(JsonSerializer.Deserialize(response, options)); + setter(JsonSerializer.Deserialize(body, options)); } catch (Exception ex) { - cardErrors[cardName] = ex.Message; + cardErrors[cardName] = FormatApiError(ex.Message); setter(null); } } + private static string FormatApiError(string? error) + { + if (string.IsNullOrEmpty(error)) return "Unknown error"; + + // Make permission errors more user-friendly + if (error.Contains("permission", StringComparison.OrdinalIgnoreCase) && + error.Contains("AndroidManifest", StringComparison.OrdinalIgnoreCase)) + { + // Extract the permission name + var tickStart = error.IndexOf('`'); + var tickEnd = tickStart >= 0 ? error.IndexOf('`', tickStart + 1) : -1; + var permName = tickStart >= 0 && tickEnd > tickStart + ? error[(tickStart + 1)..tickEnd] + : "required permission"; + return $"Missing Android permission: {permName}. Add it to your app's AndroidManifest.xml."; + } + + if (error.Contains("UIKit Consistency", StringComparison.OrdinalIgnoreCase)) + { + return "This API requires the UI thread. Update MauiDevFlow to fix this (PR #35)."; + } + + return error; + } + private RenderFragment RenderInfoTab() => __builder => {
@@ -250,15 +282,18 @@ private RenderFragment RenderInfoCard(string title, string icon, Dictionary fields) => __builder => { -
+ var hasError = cardErrors.TryGetValue(title, out var cardError); + var isPermissionError = hasError && cardError!.Contains("Missing", StringComparison.OrdinalIgnoreCase); + var cardClass = hasError ? (isPermissionError ? "info-card-warning" : "info-card-error") : ""; +
@title
- @if (cardErrors.TryGetValue(title, out var cardError)) + @if (hasError) { -
- +
+ @cardError
} @@ -1073,6 +1108,10 @@ border-color: rgba(239, 68, 68, 0.3); } + .info-card-warning { + border-color: rgba(234, 179, 8, 0.3); + } + .card-error-message { color: #ef4444; font-size: 0.75rem; @@ -1083,6 +1122,10 @@ line-height: 1.4; } + .card-error-message.card-warning { + color: var(--text-warning, #ca8a04); + } + .card-error-message i { flex-shrink: 0; margin-top: 0.1rem; From cb0f713232427b9299ba38a6859737ba0792b361 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 14:42:28 -0400 Subject: [PATCH 66/67] Support multiple concurrent sensor streams and add Stop All button - Refactor DevFlowAgentClient to use Dictionary of per-sensor WebSocket connections instead of a single shared WebSocket - Add StopSensorStream(sensor), StopAllSensorStreams(), IsSensorStreaming(), StreamingSensorCount to client API - Update UI to track readings per sensor in a Dictionary - Add 'Stop All (N)' button in toolbar when streams are active - Each sensor can independently start/stop streaming simultaneously Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/DevFlowAgentClient.cs | 61 ++++++++++++++----- .../Pages/Inspector/DevFlowPlatformTab.razor | 55 +++++++++-------- 2 files changed, 74 insertions(+), 42 deletions(-) diff --git a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs index 631f68b0..18a174eb 100644 --- a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs +++ b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs @@ -15,8 +15,7 @@ public class DevFlowAgentClient : IDisposable private CancellationTokenSource? _networkWsCts; private ClientWebSocket? _logsWs; private CancellationTokenSource? _logsWsCts; - private ClientWebSocket? _sensorWs; - private CancellationTokenSource? _sensorWsCts; + private readonly Dictionary _sensorStreams = new(); private bool _disposed; public string AgentHost { get; } @@ -553,24 +552,24 @@ public async Task StopSensorAsync(string sensor, CancellationToken ct = de public async Task StreamSensorAsync(string sensor, Action onReading, string speed = "UI", int throttleMs = 100, CancellationToken ct = default) { - _sensorWsCts?.Cancel(); - _sensorWsCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var token = _sensorWsCts.Token; + StopSensorStream(sensor); - _sensorWs?.Dispose(); - _sensorWs = new ClientWebSocket(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var ws = new ClientWebSocket(); + lock (_sensorStreams) { _sensorStreams[sensor] = (ws, cts); } + var token = cts.Token; try { var wsUrl = $"ws://{AgentHost}:{AgentPort}/ws/sensors?sensor={Uri.EscapeDataString(sensor)}&speed={Uri.EscapeDataString(speed)}&throttleMs={throttleMs}"; - await _sensorWs.ConnectAsync(new Uri(wsUrl), token); + await ws.ConnectAsync(new Uri(wsUrl), token); var buffer = new byte[16 * 1024]; var sb = new StringBuilder(); - while (_sensorWs.State == WebSocketState.Open && !token.IsCancellationRequested) + while (ws.State == WebSocketState.Open && !token.IsCancellationRequested) { - var result = await _sensorWs.ReceiveAsync(buffer, token); + var result = await ws.ReceiveAsync(buffer, token); if (result.MessageType == WebSocketMessageType.Close) break; @@ -592,17 +591,48 @@ public async Task StreamSensorAsync(string sensor, Action catch (WebSocketException) { } finally { - if (_sensorWs?.State == WebSocketState.Open) + if (ws.State == WebSocketState.Open) { - try { await _sensorWs.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); } + try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); } catch { } } + lock (_sensorStreams) { _sensorStreams.Remove(sensor); } } } - public void StopSensorStream() + public void StopSensorStream(string sensor) { - _sensorWsCts?.Cancel(); + lock (_sensorStreams) + { + if (_sensorStreams.Remove(sensor, out var entry)) + { + entry.Cts.Cancel(); + entry.Ws.Dispose(); + } + } + } + + public void StopAllSensorStreams() + { + lock (_sensorStreams) + { + foreach (var entry in _sensorStreams.Values) + { + entry.Cts.Cancel(); + entry.Ws.Dispose(); + } + _sensorStreams.Clear(); + } + } + + public bool IsSensorStreaming(string sensor) + { + lock (_sensorStreams) { return _sensorStreams.ContainsKey(sensor); } + } + + public int StreamingSensorCount + { + get { lock (_sensorStreams) { return _sensorStreams.Count; } } } // --- Helpers --- @@ -666,8 +696,7 @@ public void Dispose() _networkWs?.Dispose(); _logsWsCts?.Cancel(); _logsWs?.Dispose(); - _sensorWsCts?.Cancel(); - _sensorWs?.Dispose(); + StopAllSensorStreams(); _http.Dispose(); } } diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor index 12ee48b6..c172a3eb 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowPlatformTab.razor @@ -86,10 +86,8 @@ // Sensors state private List sensors = new(); private bool sensorsLoaded; - private string? streamingSensor; - private DevFlowSensorReading? latestReading; + private Dictionary sensorReadings = new(); private string sensorSpeed = "UI"; - private CancellationTokenSource? sensorStreamCts; // Location state private DevFlowGeolocation? location; @@ -674,10 +672,7 @@ if (currentlyActive) { await Client.StopSensorAsync(sensorName); - if (streamingSensor == sensorName) - { - StopSensorStreaming(); - } + StopSensorStreaming(sensorName); } else { @@ -688,28 +683,29 @@ private void StartSensorStreaming(string sensorName) { - StopSensorStreaming(); - streamingSensor = sensorName; - latestReading = null; - sensorStreamCts = new CancellationTokenSource(); + if (Client.IsSensorStreaming(sensorName)) return; _ = Task.Run(async () => { await Client.StreamSensorAsync(sensorName, reading => { - latestReading = reading; + sensorReadings[sensorName] = reading; InvokeAsync(StateHasChanged); - }, sensorSpeed, 100, sensorStreamCts.Token); + }, sensorSpeed, 100); }); } - private void StopSensorStreaming() + private void StopSensorStreaming(string sensorName) + { + Client.StopSensorStream(sensorName); + sensorReadings.Remove(sensorName); + } + + private void StopAllSensorStreaming() { - sensorStreamCts?.Cancel(); - sensorStreamCts = null; - Client.StopSensorStream(); - streamingSensor = null; - latestReading = null; + Client.StopAllSensorStreams(); + sensorReadings.Clear(); + StateHasChanged(); } private string GetSensorIcon(string sensor) => sensor.ToLowerInvariant() switch @@ -819,6 +815,12 @@ + @if (Client.StreamingSensorCount > 0) + { + + }