diff --git a/Luotsi.Cli.Tests/AppCommandFamilyClassifierTests.cs b/Luotsi.Cli.Tests/AppCommandFamilyClassifierTests.cs new file mode 100644 index 0000000..9e14879 --- /dev/null +++ b/Luotsi.Cli.Tests/AppCommandFamilyClassifierTests.cs @@ -0,0 +1,67 @@ +using Luotsi.Cli.Cli; +using Xunit; + +namespace Luotsi.Cli.Tests; + +public sealed class AppCommandFamilyClassifierTests +{ + [Fact] + public void Classify_ProfileList_Returns_ProfileList_Family() + { + var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["profile-list"])); + + Assert.Equal(AppCommandFamily.ProfileList, classification.Family); + Assert.Null(classification.ViewDiagnostic); + } + + [Fact] + public void Classify_Inspect_Returns_Inspect_Family() + { + var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["inspect"])); + + Assert.Equal(AppCommandFamily.Inspect, classification.Family); + Assert.Null(classification.ViewDiagnostic); + } + + [Fact] + public void Classify_ViewSetup_Alias_Returns_ViewDiagnostics_Setup_Invocation() + { + var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["view", "setup", "--device", "abc"])); + + Assert.Equal(AppCommandFamily.ViewDiagnostics, classification.Family); + var invocation = Assert.IsType(classification.ViewDiagnostic); + Assert.Equal(ViewDiagnosticAction.Setup, invocation.Action); + Assert.Equal("view-setup", invocation.EnvelopeCommand); + Assert.True(invocation.Fix); + } + + [Fact] + public void Classify_ViewDoctorFix_Returns_ViewDiagnostics_Setup_Invocation() + { + var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["view-doctor", "--device", "abc", "--fix"])); + + Assert.Equal(AppCommandFamily.ViewDiagnostics, classification.Family); + var invocation = Assert.IsType(classification.ViewDiagnostic); + Assert.Equal(ViewDiagnosticAction.Setup, invocation.Action); + Assert.Equal("view-doctor", invocation.EnvelopeCommand); + Assert.True(invocation.Fix); + } + + [Fact] + public void Classify_Reconnect_Returns_ViewSession_Family() + { + var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["reconnect"])); + + Assert.Equal(AppCommandFamily.ViewSession, classification.Family); + Assert.Null(classification.ViewDiagnostic); + } + + [Fact] + public void Classify_Devices_Returns_HostedCommand_Family() + { + var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["devices"])); + + Assert.Equal(AppCommandFamily.HostedCommand, classification.Family); + Assert.Null(classification.ViewDiagnostic); + } +} \ No newline at end of file diff --git a/Luotsi.Cli.Tests/CliParsingTests.cs b/Luotsi.Cli.Tests/CliParsingTests.cs index d4a9db9..e68f84a 100644 --- a/Luotsi.Cli.Tests/CliParsingTests.cs +++ b/Luotsi.Cli.Tests/CliParsingTests.cs @@ -52,4 +52,14 @@ public void Parse_Skips_Known_Command_Words_When_They_Are_Option_Values() Assert.Equal("devices", options.Get("package")); } + [Fact] + public void Parse_Normalizes_ViewSetup_Alias_Command_And_Removes_Alias_Argument() + { + var options = CliOptions.Parse(["view", "--device", "abc", "setup", "extra"]); + + Assert.Equal("view-setup", options.Command); + Assert.Equal(["extra"], options.Arguments); + Assert.Equal("abc", options.Get("device")); + } + } diff --git a/Luotsi.Cli.Tests/TestSupport.cs b/Luotsi.Cli.Tests/TestSupport.cs index c75439c..23a0f7e 100644 --- a/Luotsi.Cli.Tests/TestSupport.cs +++ b/Luotsi.Cli.Tests/TestSupport.cs @@ -943,6 +943,37 @@ public IViewDoctor Create(IDeviceHost deviceHost) } } +internal sealed class FakeViewSetup(Func? resultFactory = null) : IViewSetup +{ + private readonly Func _resultFactory = resultFactory ?? ((options, fix) => + new ViewSetupResult( + true, + fix, + options.PresetName, + options, + [new ViewSetupStep("helper_install", ViewStartupPhaseStatus.Succeeded, "Installed.")], + new ViewDoctorResult(true, options.PresetName, options, [], null, []))); + + public List<(ViewOptions Options, bool Fix)> Calls { get; } = []; + + public Task SetupAsync(ViewOptions options, bool fix, CancellationToken cancellationToken = default) + { + Calls.Add((options, fix)); + return Task.FromResult(_resultFactory(options, fix)); + } +} + +internal sealed class FakeViewSetupFactory(IViewSetup viewSetup) : IViewSetupFactory +{ + public IDeviceHost? LastDeviceHost { get; private set; } + + public IViewSetup Create(IDeviceHost deviceHost) + { + LastDeviceHost = deviceHost; + return viewSetup; + } +} + internal sealed class FakeViewRendererFactory(IViewRenderer renderer) : IViewRendererFactory { public Func? LastInteractionHandler { get; private set; } @@ -954,9 +985,10 @@ internal sealed class FakeViewRendererFactory(IViewRenderer renderer) : IViewRen } } -internal sealed class FakeViewTransportBootstrap(IEnumerable outcomes) : IViewTransportBootstrap +internal sealed class FakeViewTransportBootstrap(IEnumerable outcomes, IEnumerable? startupPhases = null) : IViewTransportBootstrap { private readonly Queue _outcomes = new(outcomes); + private readonly ViewStartupPhase[] _startupPhases = startupPhases?.ToArray() ?? []; public FakeViewTransportBootstrap(ViewConnectionInfo connectionInfo) : this([connectionInfo]) @@ -969,10 +1001,15 @@ public FakeViewTransportBootstrap(ViewConnectionInfo connectionInfo) public List StartRequests { get; } = []; - public Task StartAsync(ViewStartRequest request, CancellationToken cancellationToken = default) + public Task StartAsync(ViewStartRequest request, Action? reportPhase = null, CancellationToken cancellationToken = default) { StartCallCount++; StartRequests.Add(request); + foreach (var phase in _startupPhases) + { + reportPhase?.Invoke(phase); + } + var outcome = _outcomes.Count > 1 ? _outcomes.Dequeue() : _outcomes.Peek(); if (outcome is Exception exception) { diff --git a/Luotsi.Cli.Tests/ViewDoctorTests.cs b/Luotsi.Cli.Tests/ViewDoctorTests.cs index ae81a16..442af9b 100644 --- a/Luotsi.Cli.Tests/ViewDoctorTests.cs +++ b/Luotsi.Cli.Tests/ViewDoctorTests.cs @@ -1,6 +1,7 @@ using Luotsi.Cli.Cli; using Luotsi.Cli.Hosts.Android.View; using Luotsi.Cli.Models; +using Luotsi.Cli.View; using Luotsi.Cli.View.Contracts; using Luotsi.Cli.View.Diagnostics; using Luotsi.Cli.View.Recording; @@ -50,6 +51,96 @@ public async Task RunAsync_ViewDoctor_Uses_Injected_ViewDoctorFactory() Assert.Equal(250, options.StatsIntervalMs); } + [Fact] + public async Task RunAsync_ViewSetup_Uses_Injected_ViewSetupFactory() + { + var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind)); + var console = new FakeConsole(); + var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); + var setup = new FakeViewSetup(); + var factory = new FakeViewSetupFactory(setup); + var app = new App(new AppDependencies + { + Console = console, + TimeProvider = timeProvider, + DeviceHostFactory = new FakeDeviceHostFactory(host), + ViewSetupFactory = factory + }); + + var exitCode = await app.RunAsync([ + "view-setup", + "--device", "192.168.0.134:5555", + "--preset", "safe"]); + + using var envelope = console.ParseSingleOutputAsJson(); + Assert.Equal(0, exitCode); + Assert.True(envelope.RootElement.GetProperty("ok").GetBoolean()); + Assert.Equal("view-setup", envelope.RootElement.GetProperty("command").GetString()); + Assert.True(envelope.RootElement.GetProperty("data").GetProperty("fix").GetBoolean()); + Assert.True(envelope.RootElement.GetProperty("data").GetProperty("ready").GetBoolean()); + Assert.Same(host, factory.LastDeviceHost); + var call = Assert.Single(setup.Calls); + Assert.True(call.Fix); + Assert.Equal("safe", call.Options.PresetName); + } + + [Fact] + public async Task RunAsync_ViewSetup_Alias_Writes_ViewSetup_Command_Envelope() + { + var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind)); + var console = new FakeConsole(); + var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); + var setup = new FakeViewSetup(); + var fileSystem = new FakeFileSystem(); + var app = new App(new AppDependencies + { + Console = console, + TimeProvider = timeProvider, + FileSystem = fileSystem, + DeviceHostFactory = new FakeDeviceHostFactory(host), + ViewSetupFactory = new FakeViewSetupFactory(setup) + }); + + var exitCode = await app.RunAsync([ + "view", + "setup", + "--device", "192.168.0.134:5555", + "--artifacts", "/tmp/artifacts"]); + + using var envelope = console.ParseSingleOutputAsJson(); + Assert.Equal(0, exitCode); + Assert.Equal("view-setup", envelope.RootElement.GetProperty("command").GetString()); + Assert.True(Assert.Single(setup.Calls).Fix); + Assert.True(fileSystem.DirectoryExists("/tmp/artifacts/20260515-120000-view-setup")); + } + + [Fact] + public async Task RunAsync_ViewDoctor_Fix_Runs_Setup() + { + var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind)); + var console = new FakeConsole(); + var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); + var setup = new FakeViewSetup(); + var factory = new FakeViewSetupFactory(setup); + var app = new App(new AppDependencies + { + Console = console, + TimeProvider = timeProvider, + DeviceHostFactory = new FakeDeviceHostFactory(host), + ViewSetupFactory = factory + }); + + var exitCode = await app.RunAsync([ + "view-doctor", + "--device", "192.168.0.134:5555", + "--fix"]); + + using var envelope = console.ParseSingleOutputAsJson(); + Assert.Equal(0, exitCode); + Assert.Equal("view-doctor", envelope.RootElement.GetProperty("command").GetString()); + Assert.True(envelope.RootElement.GetProperty("data").GetProperty("fix").GetBoolean()); + Assert.True(Assert.Single(setup.Calls).Fix); + } [Fact] @@ -175,5 +266,112 @@ public async Task ViewDoctor_DiagnoseAsync_Flags_Explicit_MediaProjection_Consen Assert.Contains("fallback=none", consentCheck.Detail, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task AndroidViewHelperSetupProvisioner_ResolveOrBuildAsync_Skips_Build_When_Fix_Is_Disabled() + { + var environment = new FakeEnvironmentVariables(new Dictionary()); + var processRunner = new FakeProcessRunner(); + var provisioner = new AndroidViewHelperSetupProvisioner( + new SequencedAndroidViewHelperPackageLocator(new InvalidOperationException("missing helper")), + new ViewHostPathResolver(environment), + new FakeFileSystem(), + processRunner); + var steps = new List(); + + var package = await provisioner.ResolveOrBuildAsync(fix: false, steps.Add); + + Assert.Null(package); + Assert.Empty(processRunner.Calls); + Assert.Collection( + steps, + step => + { + Assert.Equal("helper_resolve", step.Name); + Assert.Equal(ViewStartupPhaseStatus.Failed, step.Status); + }, + step => + { + Assert.Equal("helper_build", step.Name); + Assert.Equal(ViewStartupPhaseStatus.Skipped, step.Status); + }); + } + + [Fact] + public async Task AndroidViewHelperSetupProvisioner_ResolveOrBuildAsync_Builds_And_Reresolves_Helper() + { + var environment = new FakeEnvironmentVariables(new Dictionary()); + var fileSystem = new FakeFileSystem(); + var pathResolver = new ViewHostPathResolver(environment); + var projectDirectory = pathResolver.GetRepositoryRelativeDirectoryCandidates("Luotsi.ViewServer.Android").First(); + var wrapperPath = OperatingSystem.IsWindows() + ? Path.Join(projectDirectory, "gradlew.bat") + : Path.Join(projectDirectory, "gradlew"); + fileSystem.CreateDirectory(projectDirectory); + fileSystem.AddFile(wrapperPath, string.Empty); + + var processRunner = new FakeProcessRunner(); + processRunner.EnqueueResult(new ProcessResult(0, "BUILD SUCCESSFUL", string.Empty)); + + var package = new AndroidViewHelperPackage("C:/tmp/helper.apk", "/data/local/tmp/luotsi-view-server.apk", "dev.luotsi.view.Main", "test-helper"); + var provisioner = new AndroidViewHelperSetupProvisioner( + new SequencedAndroidViewHelperPackageLocator(new InvalidOperationException("missing helper"), package), + pathResolver, + fileSystem, + processRunner); + var steps = new List(); + + var resolved = await provisioner.ResolveOrBuildAsync(fix: true, steps.Add); + + Assert.Same(package, resolved); + var call = Assert.Single(processRunner.Calls); + Assert.Equal(wrapperPath, call.FileName); + Assert.Equal(["-p", projectDirectory, ":app:assembleDebug"], call.Args); + Assert.Collection( + steps, + step => + { + Assert.Equal("helper_resolve", step.Name); + Assert.Equal(ViewStartupPhaseStatus.Failed, step.Status); + }, + step => + { + Assert.Equal("helper_build", step.Name); + Assert.Equal(ViewStartupPhaseStatus.Started, step.Status); + Assert.Equal(projectDirectory, step.Detail); + }, + step => + { + Assert.Equal("helper_build", step.Name); + Assert.Equal(ViewStartupPhaseStatus.Succeeded, step.Status); + Assert.Equal("BUILD SUCCESSFUL", step.Detail); + }, + step => + { + Assert.Equal("helper_resolve", step.Name); + Assert.Equal(ViewStartupPhaseStatus.Succeeded, step.Status); + }); + } + + private sealed class SequencedAndroidViewHelperPackageLocator(params object[] outcomes) : IAndroidViewHelperPackageLocator + { + private readonly Queue _outcomes = new(outcomes); + + public AndroidViewHelperPackage Resolve() + { + if (_outcomes.Count == 0) + { + throw new InvalidOperationException("No fake helper locator outcomes remain."); + } + + var outcome = _outcomes.Count > 1 ? _outcomes.Dequeue() : _outcomes.Peek(); + return outcome switch + { + AndroidViewHelperPackage package => package, + Exception exception => throw exception, + _ => throw new InvalidOperationException($"Unsupported fake helper locator outcome '{outcome.GetType().Name}'.") + }; + } + } + } diff --git a/Luotsi.Cli.Tests/ViewInteractionTests.cs b/Luotsi.Cli.Tests/ViewInteractionTests.cs index 45cb71a..4956dab 100644 --- a/Luotsi.Cli.Tests/ViewInteractionTests.cs +++ b/Luotsi.Cli.Tests/ViewInteractionTests.cs @@ -21,7 +21,7 @@ public async Task RunAsync_View_InteractionHandler_Routes_Text_Scroll_Clipboard_ var artifactOpener = new FakeArtifactFolderOpener(); var renderer = new ClosingViewRenderer(); var rendererFactory = new FakeViewRendererFactory(renderer); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -80,7 +80,7 @@ public async Task RunAsync_View_ReadOnly_Blocks_Interactive_Requests() var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var renderer = new ClosingViewRenderer(); var rendererFactory = new FakeViewRendererFactory(renderer); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -123,7 +123,7 @@ public async Task RunAsync_View_Emits_Interaction_Failure_Event() var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var renderer = new ClosingViewRenderer(); var rendererFactory = new FakeViewRendererFactory(renderer); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -157,7 +157,7 @@ public async Task RunAsync_View_Emits_Device_Shelf_When_Multiple_Devices_Are_Vis var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); host.ConnectedDevices.Add(new DeviceInfo("192.168.0.134:5555", "device", "Pixel 9")); host.ConnectedDevices.Add(new DeviceInfo("emulator-5554", "device", "Emulator")); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -186,7 +186,7 @@ public async Task RunAsync_View_InteractionHandler_Reconnects_And_Emits_Events() var renderer = new ClosingViewRenderer(); var rendererFactory = new FakeViewRendererFactory(renderer); var bootstrap = new FakeViewTransportBootstrap(new ViewConnectionInfo("session", "h264", 1, 1080, 1920, 27183, "helper", "adb-forward")); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -224,7 +224,7 @@ public async Task RunAsync_View_AutoReconnects_Before_Stream_Duration_Limit() var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var bootstrap = new FakeViewTransportBootstrap(new ViewConnectionInfo("session", "h264", 1, 1080, 1920, 27183, "helper", "adb-forward")); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -271,7 +271,7 @@ public async Task RunAsync_View_InteractionHandler_Switches_Device_And_Reconnect var renderer = new ClosingViewRenderer(); var rendererFactory = new FakeViewRendererFactory(renderer); var bootstrap = new FakeViewTransportBootstrap(new ViewConnectionInfo("session", "h264", 1, 1080, 1920, 27183, "helper", "adb-forward")); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, diff --git a/Luotsi.Cli.Tests/ViewSessionLifecycleTests.cs b/Luotsi.Cli.Tests/ViewSessionLifecycleTests.cs index 5731211..898cc00 100644 --- a/Luotsi.Cli.Tests/ViewSessionLifecycleTests.cs +++ b/Luotsi.Cli.Tests/ViewSessionLifecycleTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Luotsi.Cli.Artifacts; using Luotsi.Cli.Cli; +using Luotsi.Cli.Hosts.Android.View; using Luotsi.Cli.Models; using Luotsi.Cli.View.Contracts; using Luotsi.Cli.View.Session; @@ -19,7 +20,7 @@ public async Task RunAsync_View_Streams_Scaffold_Events() var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var backend = new FakeViewBackend("ffmpeg-native"); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -70,7 +71,7 @@ public async Task RunAsync_View_Uses_Backend_Selected_By_Decoder() ["ffmpeg"] = ffmpegBackend, ["wmf"] = wmfBackend }); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -113,7 +114,7 @@ public async Task RunAsync_View_Retries_Initial_Stream_Header_Read() .WriteHeader("h264", 1080, 1920) .WritePacket(ViewPacketType.StreamEnd, 1, 0, false, []) .Build()); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -130,6 +131,48 @@ public async Task RunAsync_View_Retries_Initial_Stream_Header_Read() Assert.Contains(console.OutputLines, line => line.Contains(SessionEventTypes.View.Started, StringComparison.Ordinal)); } + [Fact] + public async Task RunAsync_View_Streams_Startup_Phases_From_Transport_Bootstrap() + { + var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind)); + var fileSystem = new FakeFileSystem(); + var console = new FakeConsole(); + var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); + var backend = new FakeViewBackend(); + var bootstrap = new FakeViewTransportBootstrap( + [new ViewConnectionInfo("session", "h264", 1, 1080, 1920, 27183, "helper", "adb-forward")], + [new ViewStartupPhase("helper_resolve", ViewStartupPhaseStatus.Started, "Resolving Android view helper package.")]); + var session = CreateViewSession( + host, + ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), + console, + timeProvider, + bootstrap, + new FakeViewBackendFactory(backend), + new FakeViewStreamConnector( + new ViewPacketStreamHarness() + .WriteHeader("h264", 1080, 1920) + .WritePacket(ViewPacketType.StreamEnd, 1, 0, false, []) + .Build()), + new ViewPacketStreamReader()); + + var exitCode = await session.RunAsync(new ViewOptions("192.168.0.134:5555", "adb", "h264", "ffmpeg", true, null, 1600, 60, "8M", false, false)); + + Assert.Equal(0, exitCode); + Assert.Equal(3, console.OutputLines.Count); + + using var phase = JsonDocument.Parse(console.OutputLines[0]); + Assert.Equal(SessionEventTypes.View.StartupPhase, phase.RootElement.GetProperty("type").GetString()); + Assert.Equal("helper_resolve", phase.RootElement.GetProperty("phase").GetString()); + Assert.Equal(ViewStartupPhaseStatus.Started, phase.RootElement.GetProperty("status").GetString()); + + using var started = JsonDocument.Parse(console.OutputLines[1]); + Assert.Equal(SessionEventTypes.View.Started, started.RootElement.GetProperty("type").GetString()); + + using var ended = JsonDocument.Parse(console.OutputLines[2]); + Assert.Equal(SessionEventTypes.View.Ended, ended.RootElement.GetProperty("type").GetString()); + } + [Fact] public async Task RunAsync_View_AutoCaptureBackend_Falls_Back_To_Screenrecord_When_MediaProjection_Start_Fails() { @@ -142,7 +185,7 @@ public async Task RunAsync_View_AutoCaptureBackend_Falls_Back_To_Screenrecord_Wh new InvalidOperationException("mediaprojection consent was not granted"), new ViewConnectionInfo("session", "h264", 1, 1080, 1920, 27183, "helper", "adb-forward", CaptureBackend: ViewCaptureBackends.Screenrecord) ]); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -194,7 +237,7 @@ public async Task RunAsync_View_AutoCaptureBackend_Falls_Back_To_Screenrecord_Wh .WriteHeader("h264", 1080, 1920) .WritePacket(ViewPacketType.StreamEnd, 1, 0, false, []) .Build()); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -222,6 +265,51 @@ public async Task RunAsync_View_AutoCaptureBackend_Falls_Back_To_Screenrecord_Wh Assert.Equal(ViewCaptureBackends.Screenrecord, started.RootElement.GetProperty("capture_backend").GetString()); } + [Fact] + public async Task RunAsync_View_Explicit_MediaProjection_Consent_Failure_Returns_Usage_Error() + { + var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind)); + var fileSystem = new FakeFileSystem(); + var console = new FakeConsole(); + var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); + var backend = new FakeViewBackend(); + var bootstrap = new FakeViewTransportBootstrap([ + new MediaProjectionConsentException("MediaProjection consent prompt was not approved or could not be detected.") + ]); + using var stream = new MemoryStream(); + var session = CreateViewSession( + host, + ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), + console, + timeProvider, + bootstrap, + new FakeViewBackendFactory(backend), + new FakeViewStreamConnector(stream), + new ViewPacketStreamReader()); + + var exitCode = await session.RunAsync(new ViewOptions( + "192.168.0.134:5555", + "adb", + "h264", + "ffmpeg", + true, + null, + 1600, + 60, + "8M", + false, + false, + CaptureBackend: ViewCaptureBackends.MediaProjection)); + + Assert.Equal(1, exitCode); + Assert.Equal([ViewCaptureBackends.MediaProjection], bootstrap.StartRequests.Select(static request => request.CaptureBackend).ToArray()); + + using var error = JsonDocument.Parse(console.OutputLines[0]); + Assert.Equal(SessionEventTypes.View.Error, error.RootElement.GetProperty("type").GetString()); + Assert.Equal("usage_error", error.RootElement.GetProperty("error").GetProperty("category").GetString()); + Assert.Contains("--capture-backend screenrecord", error.RootElement.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal); + } + [Fact] public async Task RunAsync_View_AutoCaptureBackend_Does_Not_Fall_Back_When_Helper_Package_Is_Missing() { @@ -234,7 +322,7 @@ public async Task RunAsync_View_AutoCaptureBackend_Does_Not_Fall_Back_When_Helpe new InvalidOperationException("Android view helper package was not found. Set LUOTSI_VIEW_HELPER_APK or build the helper APK at Luotsi.ViewServer.Android\\app\\build\\outputs\\apk\\debug\\app-debug.apk") ]); using var stream = new MemoryStream(); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -271,7 +359,7 @@ public async Task RunAsync_View_Window_Close_Ends_Session_Cleanly() var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var renderer = new ClosingViewRenderer(); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, diff --git a/Luotsi.Cli.Tests/ViewSessionOptionsTests.cs b/Luotsi.Cli.Tests/ViewSessionOptionsTests.cs index 70b2d18..cac6946 100644 --- a/Luotsi.Cli.Tests/ViewSessionOptionsTests.cs +++ b/Luotsi.Cli.Tests/ViewSessionOptionsTests.cs @@ -73,6 +73,35 @@ public async Task RunAsync_View_Uses_Injected_ViewSessionFactory() Assert.True(options.OverlayScreenState); Assert.True(options.OverlayTelemetry); Assert.Equal("balanced", options.PresetName); + Assert.Equal(TimeSpan.FromSeconds(CliDefaults.DefaultAdbCommandTimeoutSeconds), options.CommandTimeout); + } + + [Fact] + public async Task RunAsync_View_Uses_Environment_Adb_Timeout_For_ViewSession() + { + var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind)); + var console = new FakeConsole(); + var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); + var session = new FakeViewSession(23); + var app = new App(new AppDependencies + { + Console = console, + TimeProvider = timeProvider, + Environment = new FakeEnvironmentVariables(new Dictionary + { + [CliDefaults.AdbCommandTimeoutEnvironmentVariable] = "17" + }), + DeviceHostFactory = new FakeDeviceHostFactory(host), + ViewSessionFactory = new FakeViewSessionFactory(session) + }); + + var exitCode = await app.RunAsync([ + "view", + "--device", "192.168.0.134:5555"]); + + Assert.Equal(23, exitCode); + var options = Assert.Single(session.Options); + Assert.Equal(TimeSpan.FromSeconds(17), options.CommandTimeout); } diff --git a/Luotsi.Cli.Tests/ViewSessionRecordingAndStatsTests.cs b/Luotsi.Cli.Tests/ViewSessionRecordingAndStatsTests.cs index 809e553..1199aca 100644 --- a/Luotsi.Cli.Tests/ViewSessionRecordingAndStatsTests.cs +++ b/Luotsi.Cli.Tests/ViewSessionRecordingAndStatsTests.cs @@ -20,7 +20,7 @@ public async Task RunAsync_View_With_Record_Path_Creates_Recorder_And_Emits_Star var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var backend = new FakeViewBackend("ffmpeg-native"); var recorderFactory = new FakeViewRecorderFactory(); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -65,7 +65,7 @@ public async Task RunAsync_View_Emits_ViewStats_Jsonl_Events() var fileSystem = new FakeFileSystem(); var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -106,7 +106,7 @@ public async Task RunAsync_View_Throttles_ViewStats_Jsonl_Events_And_Flushes_The var fileSystem = new FakeFileSystem(); var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -151,7 +151,7 @@ public async Task RunAsync_View_Uses_Configured_ViewStats_Interval() var fileSystem = new FakeFileSystem(); var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -197,7 +197,7 @@ public async Task RunAsync_View_Can_Throttle_Renderer_Stats_Separately_From_Json var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var renderer = new StatsCapturingViewRenderer(); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -244,7 +244,7 @@ public async Task RunAsync_View_With_Zero_Stats_Interval_Disables_Jsonl_Stats_An var console = new FakeConsole(); var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in")); var renderer = new StatsCapturingViewRenderer(); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -287,7 +287,7 @@ public async Task RunAsync_View_InteractionHandler_Toggles_Recording_And_Emits_E var renderer = new ClosingViewRenderer(); var rendererFactory = new FakeViewRendererFactory(renderer); var recorderFactory = new FakeViewRecorderFactory(); - var session = new ViewSession( + var session = CreateViewSession( host, ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, diff --git a/Luotsi.Cli.Tests/ViewSessionSharingTests.cs b/Luotsi.Cli.Tests/ViewSessionSharingTests.cs index 4a97c4b..300a64d 100644 --- a/Luotsi.Cli.Tests/ViewSessionSharingTests.cs +++ b/Luotsi.Cli.Tests/ViewSessionSharingTests.cs @@ -86,7 +86,7 @@ public async Task RunAsync_View_JoinShare_Consumes_Shared_Tcp_Stream_Without_Sta var console = new FakeConsole(); var backend = new FakeViewBackend(); var bootstrap = new FakeViewTransportBootstrap(new ViewConnectionInfo("session", "h264", 1, 1080, 1920, 27183, "helper", "adb-forward")); - var session = new ViewSession( + var session = CreateViewSession( new UnsupportedDeviceHost(), ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, @@ -117,7 +117,7 @@ public async Task RunAsync_View_JoinShare_Blocks_Recording_Command() var console = new FakeConsole(); var renderer = new ClosingViewRenderer(); var rendererFactory = new FakeViewRendererFactory(renderer); - var session = new ViewSession( + var session = CreateViewSession( new UnsupportedDeviceHost(), ArtifactSession.Create(CliOptions.Parse(["view"]), fileSystem, timeProvider), console, diff --git a/Luotsi.Cli.Tests/ViewSessionTestSupport.cs b/Luotsi.Cli.Tests/ViewSessionTestSupport.cs new file mode 100644 index 0000000..bae945f --- /dev/null +++ b/Luotsi.Cli.Tests/ViewSessionTestSupport.cs @@ -0,0 +1,40 @@ +using Luotsi.Cli.Artifacts; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.View.Contracts; +using Luotsi.Cli.View.Session; +using Luotsi.Cli.View.Transport; + +namespace Luotsi.Cli.Tests; + +public sealed partial class AppTests +{ + private static ViewSession CreateViewSession( + IDeviceHost deviceHost, + ArtifactSession artifacts, + IConsoleIo console, + TimeProvider timeProvider, + IViewTransportBootstrap transportBootstrap, + IViewBackendFactory viewBackendFactory, + IViewStreamConnector streamConnector, + IViewPacketStreamReader? packetStreamReader = null, + IViewRendererFactory? viewRendererFactory = null, + IViewRecorderFactory? viewRecorderFactory = null, + IArtifactFolderOpener? artifactFolderOpener = null, + TimeSpan? autoReconnectAfter = null) => + new( + deviceHost, + artifacts, + new ViewSessionRuntime + { + Console = console, + TimeProvider = timeProvider, + TransportBootstrap = transportBootstrap, + ViewBackendFactory = viewBackendFactory, + StreamConnector = streamConnector, + PacketStreamReader = packetStreamReader ?? new ViewPacketStreamReader(), + ViewRendererFactory = viewRendererFactory, + ViewRecorderFactory = viewRecorderFactory, + ArtifactFolderOpener = artifactFolderOpener, + AutoReconnectAfter = autoReconnectAfter + }); +} \ No newline at end of file diff --git a/Luotsi.Cli.Tests/ViewTransportTests.cs b/Luotsi.Cli.Tests/ViewTransportTests.cs index c0eecbe..bb2ed5a 100644 --- a/Luotsi.Cli.Tests/ViewTransportTests.cs +++ b/Luotsi.Cli.Tests/ViewTransportTests.cs @@ -75,8 +75,9 @@ public async Task AndroidViewBootstrap_StartAsync_Pushes_Forwards_And_Starts_Hel adb.EnqueueShellResult(new ProcessResult(0, string.Empty, string.Empty)); var locator = new FakeAndroidViewHelperPackageLocator(new AndroidViewHelperPackage("C:/tmp/helper.apk", "/data/local/tmp/luotsi-view-server.apk", "dev.luotsi.view.Main", "test-helper")); var bootstrap = new AndroidViewBootstrap(new FakeAdbClientFactory(adb), new DefaultProcessRunner(), locator, new FakeUniqueIdGenerator("session123")); + var phases = new List(); - var connection = await bootstrap.StartAsync(new ViewStartRequest("adb", "device-1", 1280, 30, "8M", "h264", ViewCaptureBackends.Screenrecord)); + var connection = await bootstrap.StartAsync(new ViewStartRequest("adb", "device-1", 1280, 30, "8M", "h264", ViewCaptureBackends.Screenrecord), phases.Add); Assert.Equal("session123", connection.SessionId); Assert.Equal("h264", connection.Codec); @@ -86,6 +87,10 @@ public async Task AndroidViewBootstrap_StartAsync_Pushes_Forwards_And_Starts_Hel Assert.Equal(["forward", "tcp:0", "localabstract:luotsi_view_session123"], adb.RunCommands[1]); Assert.Contains("CLASSPATH='/data/local/tmp/luotsi-view-server.apk' app_process / 'dev.luotsi.view.Main'", adb.ShellCommands[0], StringComparison.Ordinal); Assert.Contains("--codec 'h264'", adb.ShellCommands[0], StringComparison.Ordinal); + Assert.Contains(phases, phase => phase.Phase == "helper_resolve" && phase.Status == ViewStartupPhaseStatus.Succeeded); + Assert.Contains(phases, phase => phase.Phase == "helper_push" && phase.Status == ViewStartupPhaseStatus.Succeeded); + Assert.Contains(phases, phase => phase.Phase == "adb_forward" && phase.Status == ViewStartupPhaseStatus.Succeeded); + Assert.Contains(phases, phase => phase.Phase == "screenrecord_process" && phase.Status == ViewStartupPhaseStatus.Succeeded); } [Fact] @@ -181,6 +186,8 @@ public async Task AndroidViewBootstrap_StartAsync_Installs_And_Launches_MediaPro { var adb = new FakeAdbClient(); adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "dev.luotsi.view/.ConsentActivity\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "Service dev.luotsi.view.CaptureService:\n", string.Empty)); adb.EnqueueRunResult(new ProcessResult(0, "38543\n", string.Empty)); adb.EnqueueRunResult(new ProcessResult(0, "Starting: Intent { cmp=dev.luotsi.view/.ConsentActivity }\n", string.Empty)); adb.EnqueueShellResult(new ProcessResult(0, """ @@ -197,12 +204,14 @@ public async Task AndroidViewBootstrap_StartAsync_Installs_And_Launches_MediaPro Assert.Equal(ViewCaptureBackends.MediaProjection, connection.CaptureBackend); Assert.Equal(["install", "-r", "C:/tmp/helper.apk"], adb.RunCommands[0]); - Assert.Equal(["forward", "tcp:0", "localabstract:luotsi_view_session123"], adb.RunCommands[1]); - Assert.Equal("shell", adb.RunCommands[2][0]); - Assert.Equal("am", adb.RunCommands[2][1]); - Assert.Equal("start", adb.RunCommands[2][2]); - Assert.Contains("dev.luotsi.view/.ConsentActivity", adb.RunCommands[2], StringComparer.Ordinal); - Assert.Contains("luotsi_view_session123", adb.RunCommands[2], StringComparer.Ordinal); + Assert.Equal(["shell", "cmd", "package", "resolve-activity", "--brief", "dev.luotsi.view/.ConsentActivity"], adb.RunCommands[1]); + Assert.Equal(["shell", "pm", "dump", "dev.luotsi.view"], adb.RunCommands[2]); + Assert.Equal(["forward", "tcp:0", "localabstract:luotsi_view_session123"], adb.RunCommands[3]); + Assert.Equal("shell", adb.RunCommands[4][0]); + Assert.Equal("am", adb.RunCommands[4][1]); + Assert.Equal("start", adb.RunCommands[4][2]); + Assert.Contains("dev.luotsi.view/.ConsentActivity", adb.RunCommands[4], StringComparer.Ordinal); + Assert.Contains("luotsi_view_session123", adb.RunCommands[4], StringComparer.Ordinal); Assert.Contains("uiautomator dump /data/local/tmp/luotsi-view-window.xml", adb.ShellCommands[0], StringComparison.Ordinal); Assert.Contains("cat /data/local/tmp/luotsi-view-window.xml", adb.ShellCommands[0], StringComparison.Ordinal); Assert.Equal("input tap 1276 665", adb.ShellCommands[1]); @@ -213,6 +222,8 @@ public async Task AndroidViewBootstrap_StartAsync_Retries_File_Ui_Dump_For_Media { var adb = new FakeAdbClient(); adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "dev.luotsi.view/.ConsentActivity\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "Service dev.luotsi.view.CaptureService:\n", string.Empty)); adb.EnqueueRunResult(new ProcessResult(0, "38543\n", string.Empty)); adb.EnqueueRunResult(new ProcessResult(0, "Starting: Intent { cmp=dev.luotsi.view/.ConsentActivity }\n", string.Empty)); adb.EnqueueShellResult(new ProcessResult(0, """ @@ -238,6 +249,61 @@ public async Task AndroidViewBootstrap_StartAsync_Retries_File_Ui_Dump_For_Media Assert.Equal("input tap 1276 665", adb.ShellCommands[2]); } + [Fact] + public async Task AndroidViewBootstrap_StartAsync_Auto_Reports_Consent_Error_When_Consent_Is_Not_Detected() + { + var adb = new FakeAdbClient(); + adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "dev.luotsi.view/.ConsentActivity\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "Service dev.luotsi.view.CaptureService:\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "38543\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "Starting: Intent { cmp=dev.luotsi.view/.ConsentActivity }\n", string.Empty)); + for (var i = 0; i < 8; i++) + { + adb.EnqueueShellResult(new ProcessResult(0, """ + + + """, string.Empty)); + } + adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueShellResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + var locator = new FakeAndroidViewHelperPackageLocator(new AndroidViewHelperPackage("C:/tmp/helper.apk", "/data/local/tmp/luotsi-view-server.apk", "dev.luotsi.view.Main", "test-helper")); + var bootstrap = new AndroidViewBootstrap(new FakeAdbClientFactory(adb), new DefaultProcessRunner(), locator, new FakeUniqueIdGenerator("session123")); + + var error = await Assert.ThrowsAsync(() => bootstrap.StartAsync(new ViewStartRequest("adb", "device-1", 1280, 30, "8M", "h264"))); + + Assert.Contains("MediaProjection consent", error.Message, StringComparison.Ordinal); + Assert.Equal(8, adb.ShellCommands.Count(command => command.Contains("uiautomator dump", StringComparison.Ordinal))); + } + + [Fact] + public async Task AndroidViewBootstrap_StartAsync_Explicit_MediaProjection_Reports_Consent_Error_When_Consent_Is_Not_Detected() + { + var adb = new FakeAdbClient(); + adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "dev.luotsi.view/.ConsentActivity\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "Service dev.luotsi.view.CaptureService:\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "38543\n", string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, "Starting: Intent { cmp=dev.luotsi.view/.ConsentActivity }\n", string.Empty)); + for (var i = 0; i < 8; i++) + { + adb.EnqueueShellResult(new ProcessResult(0, """ + + + """, string.Empty)); + } + adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueShellResult(new ProcessResult(0, string.Empty, string.Empty)); + adb.EnqueueRunResult(new ProcessResult(0, string.Empty, string.Empty)); + var locator = new FakeAndroidViewHelperPackageLocator(new AndroidViewHelperPackage("C:/tmp/helper.apk", "/data/local/tmp/luotsi-view-server.apk", "dev.luotsi.view.Main", "test-helper")); + var bootstrap = new AndroidViewBootstrap(new FakeAdbClientFactory(adb), new DefaultProcessRunner(), locator, new FakeUniqueIdGenerator("session123")); + + var error = await Assert.ThrowsAsync(() => bootstrap.StartAsync(new ViewStartRequest("adb", "device-1", 1280, 30, "8M", "h264", ViewCaptureBackends.MediaProjection))); + + Assert.Contains("MediaProjection consent", error.Message, StringComparison.Ordinal); + } + [Fact] public void AndroidViewHelperPackageLocator_Uses_Default_Project_Output_When_Environment_Is_Missing() { diff --git a/Luotsi.Cli/Cli/AdbCommandTimeoutResolver.cs b/Luotsi.Cli/Cli/AdbCommandTimeoutResolver.cs new file mode 100644 index 0000000..5caedcf --- /dev/null +++ b/Luotsi.Cli/Cli/AdbCommandTimeoutResolver.cs @@ -0,0 +1,24 @@ +using Luotsi.Cli.Errors; +using Luotsi.Cli.Infrastructure.Contracts; + +namespace Luotsi.Cli.Cli; + +internal static class AdbCommandTimeoutResolver +{ + public static TimeSpan? Resolve(CliOptions options, IEnvironmentVariables environment) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(environment); + + var rawValue = options.Get("adb-timeout-sec") ?? + environment.GetEnvironmentVariable(CliDefaults.AdbCommandTimeoutEnvironmentVariable) ?? + CliDefaults.DefaultAdbCommandTimeoutSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (!int.TryParse(rawValue, out var timeoutSec) || timeoutSec < 0) + { + throw new UsageException("Option --adb-timeout-sec must be a non-negative integer."); + } + + return timeoutSec == 0 ? null : TimeSpan.FromSeconds(timeoutSec); + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Cli/App.cs b/Luotsi.Cli/Cli/App.cs index 24777e4..179f529 100644 --- a/Luotsi.Cli/Cli/App.cs +++ b/Luotsi.Cli/Cli/App.cs @@ -1,12 +1,3 @@ -using Luotsi.Cli.Infrastructure.Devices; -using Luotsi.Cli.Infrastructure.Ids; -using Luotsi.Cli.Infrastructure.Processes; -using Luotsi.Cli.Infrastructure.System; -using Luotsi.Cli.Infrastructure.Time; -using Luotsi.Cli.Scenarios; -using Luotsi.Cli.View.Diagnostics; -using Luotsi.Cli.View.Session; - namespace Luotsi.Cli.Cli; /// @@ -33,73 +24,43 @@ public App(AppDependencies? dependencies) { dependencies ??= new AppDependencies(); - var resolvedTimeProvider = dependencies.TimeProvider ?? TimeProvider.System; - var resolvedFileSystem = dependencies.FileSystem ?? new PhysicalFileSystem(); - var resolvedProcessRunner = dependencies.ProcessRunner ?? new DefaultProcessRunner(); - var resolvedDelay = dependencies.Delay ?? new TaskDelay(resolvedTimeProvider); - var resolvedConsole = dependencies.Console ?? new SystemConsoleIo(); - var resolvedEnvironment = dependencies.Environment ?? new SystemEnvironmentVariables(); - var resolvedIdGenerator = dependencies.IdGenerator ?? new GuidUniqueIdGenerator(); - var resolvedAdbClientFactory = dependencies.AdbClientFactory ?? new DefaultAdbClientFactory(); - var resolvedDeviceHostFactory = dependencies.DeviceHostFactory ?? new DefaultDeviceHostFactory( - resolvedAdbClientFactory, - resolvedProcessRunner, - resolvedDelay, - resolvedFileSystem, - resolvedTimeProvider, - resolvedEnvironment, - resolvedIdGenerator); - var resolvedViewSessionFactory = dependencies.ViewSessionFactory ?? new DefaultViewSessionFactory( - resolvedConsole, - resolvedTimeProvider, - resolvedAdbClientFactory, - resolvedProcessRunner, - resolvedEnvironment, - resolvedFileSystem, - resolvedIdGenerator); - var resolvedViewDoctorFactory = dependencies.ViewDoctorFactory ?? new DefaultViewDoctorFactory( - resolvedEnvironment, - resolvedFileSystem, - resolvedProcessRunner); - var resolvedViewProfileStore = dependencies.ViewProfileStore ?? new JsonViewProfileStore(resolvedFileSystem, resolvedEnvironment); - var profileCoordinator = new ViewProfileCoordinator(resolvedViewProfileStore); - var scenarioTemplateResolver = new ScenarioTemplateResolver(resolvedTimeProvider, resolvedEnvironment); - var scenarioCatalog = new ScenarioCatalog(resolvedFileSystem, scenarioTemplateResolver); - var scenarioRunPlanner = new ScenarioRunPlanner(scenarioCatalog); - var scenarioExecutorFactory = new ScenarioExecutorFactory(resolvedFileSystem, resolvedTimeProvider, resolvedDelay, scenarioTemplateResolver); - var scenarioBatchExecutorFactory = new ScenarioBatchExecutorFactory(scenarioExecutorFactory); - var scenarioRunEventCoordinatorFactory = new ScenarioRunEventCoordinatorFactory(resolvedFileSystem, resolvedTimeProvider); - var envelopeWriter = new AppCommandEnvelopeWriter(resolvedConsole, resolvedTimeProvider); - var commandDispatcher = new AppCommandDispatcher( - new AdbSubcommandDispatcher(), - new ScenarioCommandDispatcher(scenarioRunPlanner, scenarioExecutorFactory, scenarioBatchExecutorFactory, scenarioRunEventCoordinatorFactory), - profileCoordinator); - var commandHost = new AppCommandHost(new AppCommandHostDependencies - { - EnvelopeWriter = envelopeWriter, - ExitCodeResolver = new AppCommandExitCodeResolver(), - ProfileCoordinator = profileCoordinator, - CommandDispatcher = commandDispatcher, - ViewDoctorFactory = resolvedViewDoctorFactory - }); - var deviceHostLauncher = new DeviceHostLauncher(resolvedDeviceHostFactory, resolvedEnvironment); + var infrastructure = AppInfrastructureCompositionBuilder.Build(dependencies); + var hostedCommands = AppHostedCommandCompositionBuilder.Build(new( + infrastructure.TimeProvider, + infrastructure.Console, + infrastructure.FileSystem, + infrastructure.Environment, + infrastructure.Delay, + infrastructure.ProfileCoordinator)); + var viewCommands = AppViewCommandCompositionBuilder.Build(new( + dependencies, + infrastructure.TimeProvider, + infrastructure.Console, + infrastructure.Environment, + infrastructure.FileSystem, + infrastructure.ProcessRunner, + infrastructure.AdbClientFactory, + infrastructure.IdGenerator, + hostedCommands.EnvelopeWriter, + infrastructure.ProfileCoordinator, + infrastructure.DeviceHostLauncher)); _executionShell = new AppExecutionShell(new AppExecutionShellDependencies { - Console = resolvedConsole, - TimeProvider = resolvedTimeProvider, - FailureResponder = new AppCommandFailureResponder(envelopeWriter) + Console = infrastructure.Console, + TimeProvider = infrastructure.TimeProvider, + FailureResponder = new AppCommandFailureResponder(hostedCommands.EnvelopeWriter) }); _commandFamilyRouter = new AppCommandFamilyRouter(new AppCommandFamilyRouterDependencies { - TimeProvider = resolvedTimeProvider, - FileSystem = resolvedFileSystem, - Environment = resolvedEnvironment, - ProfileCoordinator = profileCoordinator, - CommandHost = commandHost, - ViewSessionCommandPreparer = new ViewSessionCommandPreparer(deviceHostLauncher, resolvedViewSessionFactory, profileCoordinator), - InspectSessionLauncher = new InspectSessionLauncher(deviceHostLauncher, resolvedConsole, resolvedTimeProvider), - ViewDoctorLauncher = new ViewDoctorLauncher(deviceHostLauncher, commandHost), - DeviceHostLauncher = deviceHostLauncher + TimeProvider = infrastructure.TimeProvider, + FileSystem = infrastructure.FileSystem, + Environment = infrastructure.Environment, + ProfileCoordinator = infrastructure.ProfileCoordinator, + CommandHost = hostedCommands.CommandHost, + ViewSessionCommandPreparer = viewCommands.ViewSessionCommandPreparer, + InspectSessionLauncher = new InspectSessionLauncher(infrastructure.DeviceHostLauncher, infrastructure.Console, infrastructure.TimeProvider), + ViewDiagnosticsLauncher = viewCommands.ViewDiagnosticsLauncher, + DeviceHostLauncher = infrastructure.DeviceHostLauncher }); } diff --git a/Luotsi.Cli/Cli/AppCommandFamilyClassifier.cs b/Luotsi.Cli/Cli/AppCommandFamilyClassifier.cs new file mode 100644 index 0000000..cf9ac64 --- /dev/null +++ b/Luotsi.Cli/Cli/AppCommandFamilyClassifier.cs @@ -0,0 +1,55 @@ +namespace Luotsi.Cli.Cli; + +internal enum AppCommandFamily +{ + ProfileList, + ProfileDelete, + Inspect, + ViewDiagnostics, + ViewSession, + HostedCommand +} + +internal static class AppCommandFamilyClassifier +{ + public static AppCommandFamilyClassification Classify(CliOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (string.Equals(options.Command, "profile-list", StringComparison.OrdinalIgnoreCase)) + { + return new AppCommandFamilyClassification(AppCommandFamily.ProfileList); + } + + if (string.Equals(options.Command, "profile-delete", StringComparison.OrdinalIgnoreCase)) + { + return new AppCommandFamilyClassification(AppCommandFamily.ProfileDelete); + } + + if (string.Equals(options.Command, "inspect", StringComparison.OrdinalIgnoreCase)) + { + return new AppCommandFamilyClassification(AppCommandFamily.Inspect); + } + + var viewDiagnostic = ViewDiagnosticInvocation.Resolve(options); + if (viewDiagnostic is not null) + { + return new AppCommandFamilyClassification(AppCommandFamily.ViewDiagnostics, viewDiagnostic); + } + + if (IsViewSessionCommand(options.Command)) + { + return new AppCommandFamilyClassification(AppCommandFamily.ViewSession); + } + + return new AppCommandFamilyClassification(AppCommandFamily.HostedCommand); + } + + private static bool IsViewSessionCommand(string? command) => + string.Equals(command, "view", StringComparison.OrdinalIgnoreCase) + || string.Equals(command, "reconnect", StringComparison.OrdinalIgnoreCase); +} + +internal readonly record struct AppCommandFamilyClassification( + AppCommandFamily Family, + ViewDiagnosticInvocation? ViewDiagnostic = null); \ No newline at end of file diff --git a/Luotsi.Cli/Cli/AppCommandFamilyRouter.cs b/Luotsi.Cli/Cli/AppCommandFamilyRouter.cs index b54e4ab..1d68a12 100644 --- a/Luotsi.Cli/Cli/AppCommandFamilyRouter.cs +++ b/Luotsi.Cli/Cli/AppCommandFamilyRouter.cs @@ -19,48 +19,48 @@ public async Task DispatchAsync(AppExecutionContext context) var artifacts = ArtifactSession.Create(options, _dependencies.FileSystem, _dependencies.TimeProvider); context.Artifacts = artifacts; - if (string.Equals(options.Command, "profile-list", StringComparison.OrdinalIgnoreCase)) + var classification = AppCommandFamilyClassifier.Classify(options); + switch (classification.Family) { - return await _dependencies.CommandHost.RunProfileListAsync(options, started, artifacts).ConfigureAwait(false); - } + case AppCommandFamily.ProfileList: + return await _dependencies.CommandHost.RunProfileListAsync(options, started, artifacts).ConfigureAwait(false); - if (string.Equals(options.Command, "profile-delete", StringComparison.OrdinalIgnoreCase)) - { - return await _dependencies.CommandHost.RunProfileDeleteAsync(options, started, artifacts).ConfigureAwait(false); - } + case AppCommandFamily.ProfileDelete: + return await _dependencies.CommandHost.RunProfileDeleteAsync(options, started, artifacts).ConfigureAwait(false); - if (string.Equals(options.Command, "inspect", StringComparison.OrdinalIgnoreCase)) - { - return await _dependencies.InspectSessionLauncher.RunAsync(options, adbExecutable, artifacts).ConfigureAwait(false); - } + case AppCommandFamily.Inspect: + return await _dependencies.InspectSessionLauncher.RunAsync(options, adbExecutable, artifacts).ConfigureAwait(false); - if (IsViewCommand(options.Command)) - { - var preparedViewSession = await _dependencies.ViewSessionCommandPreparer.PrepareAsync(options, adbExecutable, artifacts).ConfigureAwait(false); - context.Runner = preparedViewSession.Runner; - var exitCode = await preparedViewSession.Session.RunAsync(preparedViewSession.Options).ConfigureAwait(false); - if (exitCode == 0) + case AppCommandFamily.ViewDiagnostics: { - await _dependencies.ViewSessionCommandPreparer.SaveLastAsync(options, preparedViewSession.Options).ConfigureAwait(false); + var preparedViewDiagnostic = _dependencies.ViewDiagnosticsLauncher.Prepare( + classification.ViewDiagnostic ?? throw new InvalidOperationException("View diagnostics classification requires an invocation."), + options, + started, + adbExecutable, + artifacts); + context.Runner = preparedViewDiagnostic.Runner; + return await preparedViewDiagnostic.ExecuteAsync().ConfigureAwait(false); } - return exitCode; - } + case AppCommandFamily.ViewSession: + { + var preparedViewSession = await _dependencies.ViewSessionCommandPreparer.PrepareAsync(options, adbExecutable, artifacts).ConfigureAwait(false); + context.Runner = preparedViewSession.Runner; + var exitCode = await preparedViewSession.Session.RunAsync(preparedViewSession.Options).ConfigureAwait(false); + if (exitCode == 0) + { + await _dependencies.ViewSessionCommandPreparer.SaveLastAsync(options, preparedViewSession.Options).ConfigureAwait(false); + } + + return exitCode; + } - if (string.Equals(options.Command, "view-doctor", StringComparison.OrdinalIgnoreCase)) - { - var preparedViewDoctor = _dependencies.ViewDoctorLauncher.Prepare(options, started, adbExecutable, artifacts); - context.Runner = preparedViewDoctor.Runner; - return await preparedViewDoctor.ExecuteAsync().ConfigureAwait(false); + default: + context.Runner = _dependencies.DeviceHostLauncher.Create(options, adbExecutable, artifacts); + return await _dependencies.CommandHost.RunCommandAsync(options, started, adbExecutable, context.Runner, artifacts).ConfigureAwait(false); } - - context.Runner = _dependencies.DeviceHostLauncher.Create(options, adbExecutable, artifacts); - return await _dependencies.CommandHost.RunCommandAsync(options, started, adbExecutable, context.Runner, artifacts).ConfigureAwait(false); } - - private static bool IsViewCommand(string? command) => - string.Equals(command, "view", StringComparison.OrdinalIgnoreCase) - || string.Equals(command, "reconnect", StringComparison.OrdinalIgnoreCase); } internal sealed class AppCommandFamilyRouterDependencies @@ -79,7 +79,7 @@ internal sealed class AppCommandFamilyRouterDependencies public required InspectSessionLauncher InspectSessionLauncher { get; init; } - public required ViewDoctorLauncher ViewDoctorLauncher { get; init; } + public required ViewDiagnosticsLauncher ViewDiagnosticsLauncher { get; init; } public required DeviceHostLauncher DeviceHostLauncher { get; init; } -} \ No newline at end of file +} diff --git a/Luotsi.Cli/Cli/AppCommandHost.cs b/Luotsi.Cli/Cli/AppCommandHost.cs index effb4cd..7d03619 100644 --- a/Luotsi.Cli/Cli/AppCommandHost.cs +++ b/Luotsi.Cli/Cli/AppCommandHost.cs @@ -1,7 +1,6 @@ using Luotsi.Cli.Artifacts; using Luotsi.Cli.Infrastructure.Contracts; using Luotsi.Cli.Models; -using Luotsi.Cli.View.Diagnostics; namespace Luotsi.Cli.Cli; @@ -30,18 +29,6 @@ public async Task RunProfileDeleteAsync(CliOptions options, DateTimeOffset return 0; } - public async Task RunViewDoctorAsync(CliOptions options, DateTimeOffset started, string adbExecutable, IDeviceHost runner, ArtifactSession artifacts) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(runner); - ArgumentNullException.ThrowIfNull(artifacts); - - var viewDoctor = _dependencies.ViewDoctorFactory.Create(runner); - var report = await viewDoctor.DiagnoseAsync(ViewCommandOptionsFactory.Build(options, adbExecutable, allowJoinShare: false)).ConfigureAwait(false); - _dependencies.EnvelopeWriter.WriteSuccess(options.Command!, started, report, artifacts.ToData()); - return 0; - } - public async Task RunCommandAsync(CliOptions options, DateTimeOffset started, string adbExecutable, IDeviceHost runner, ArtifactSession artifacts) { ArgumentNullException.ThrowIfNull(options); @@ -54,15 +41,8 @@ public async Task RunCommandAsync(CliOptions options, DateTimeOffset starte } } -internal sealed class AppCommandHostDependencies -{ - public required AppCommandEnvelopeWriter EnvelopeWriter { get; init; } - - public required AppCommandExitCodeResolver ExitCodeResolver { get; init; } - - public required ViewProfileCoordinator ProfileCoordinator { get; init; } - - public required AppCommandDispatcher CommandDispatcher { get; init; } - - public required IViewDoctorFactory ViewDoctorFactory { get; init; } -} +internal sealed record AppCommandHostDependencies( + AppCommandEnvelopeWriter EnvelopeWriter, + AppCommandExitCodeResolver ExitCodeResolver, + ViewProfileCoordinator ProfileCoordinator, + AppCommandDispatcher CommandDispatcher); diff --git a/Luotsi.Cli/Cli/AppDependencies.cs b/Luotsi.Cli/Cli/AppDependencies.cs index c14c88c..0b1c096 100644 --- a/Luotsi.Cli/Cli/AppDependencies.cs +++ b/Luotsi.Cli/Cli/AppDependencies.cs @@ -64,8 +64,13 @@ public sealed class AppDependencies /// public IViewDoctorFactory? ViewDoctorFactory { get; init; } + /// + /// Gets or sets the view setup factory used by the application. + /// + public IViewSetupFactory? ViewSetupFactory { get; init; } + /// /// Gets or sets the profile store used by the application. /// public IViewProfileStore? ViewProfileStore { get; init; } -} \ No newline at end of file +} diff --git a/Luotsi.Cli/Cli/AppHostedCommandCompositionBuilder.cs b/Luotsi.Cli/Cli/AppHostedCommandCompositionBuilder.cs new file mode 100644 index 0000000..a0f3b46 --- /dev/null +++ b/Luotsi.Cli/Cli/AppHostedCommandCompositionBuilder.cs @@ -0,0 +1,44 @@ +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Scenarios; + +namespace Luotsi.Cli.Cli; + +internal static class AppHostedCommandCompositionBuilder +{ + public static AppHostedCommandComposition Build(AppHostedCommandCompositionBuilderDependencies dependencies) + { + ArgumentNullException.ThrowIfNull(dependencies); + + var scenarioTemplateResolver = new ScenarioTemplateResolver(dependencies.TimeProvider, dependencies.Environment); + var scenarioCatalog = new ScenarioCatalog(dependencies.FileSystem, scenarioTemplateResolver); + var scenarioRunPlanner = new ScenarioRunPlanner(scenarioCatalog); + var scenarioExecutorFactory = new ScenarioExecutorFactory(dependencies.FileSystem, dependencies.TimeProvider, dependencies.Delay, scenarioTemplateResolver); + var scenarioBatchExecutorFactory = new ScenarioBatchExecutorFactory(scenarioExecutorFactory); + var scenarioRunEventCoordinatorFactory = new ScenarioRunEventCoordinatorFactory(dependencies.FileSystem, dependencies.TimeProvider); + var envelopeWriter = new AppCommandEnvelopeWriter(dependencies.Console, dependencies.TimeProvider); + var commandDispatcher = new AppCommandDispatcher( + new AdbSubcommandDispatcher(), + new ScenarioCommandDispatcher(scenarioRunPlanner, scenarioExecutorFactory, scenarioBatchExecutorFactory, scenarioRunEventCoordinatorFactory), + dependencies.ProfileCoordinator); + + return new( + envelopeWriter, + new AppCommandHost(new( + envelopeWriter, + new AppCommandExitCodeResolver(), + dependencies.ProfileCoordinator, + commandDispatcher))); + } +} + +internal sealed record AppHostedCommandCompositionBuilderDependencies( + TimeProvider TimeProvider, + IConsoleIo Console, + IFileSystem FileSystem, + IEnvironmentVariables Environment, + IDelay Delay, + ViewProfileCoordinator ProfileCoordinator); + +internal sealed record AppHostedCommandComposition( + AppCommandEnvelopeWriter EnvelopeWriter, + AppCommandHost CommandHost); \ No newline at end of file diff --git a/Luotsi.Cli/Cli/AppInfrastructureCompositionBuilder.cs b/Luotsi.Cli/Cli/AppInfrastructureCompositionBuilder.cs new file mode 100644 index 0000000..5860e00 --- /dev/null +++ b/Luotsi.Cli/Cli/AppInfrastructureCompositionBuilder.cs @@ -0,0 +1,59 @@ +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Infrastructure.Devices; +using Luotsi.Cli.Infrastructure.Ids; +using Luotsi.Cli.Infrastructure.Processes; +using Luotsi.Cli.Infrastructure.System; +using Luotsi.Cli.Infrastructure.Time; + +namespace Luotsi.Cli.Cli; + +internal static class AppInfrastructureCompositionBuilder +{ + public static AppInfrastructureComposition Build(AppDependencies dependencies) + { + ArgumentNullException.ThrowIfNull(dependencies); + + var timeProvider = dependencies.TimeProvider ?? TimeProvider.System; + var fileSystem = dependencies.FileSystem ?? new PhysicalFileSystem(); + var processRunner = dependencies.ProcessRunner ?? new DefaultProcessRunner(); + var delay = dependencies.Delay ?? new TaskDelay(timeProvider); + var console = dependencies.Console ?? new SystemConsoleIo(); + var environment = dependencies.Environment ?? new SystemEnvironmentVariables(); + var idGenerator = dependencies.IdGenerator ?? new GuidUniqueIdGenerator(); + var adbClientFactory = dependencies.AdbClientFactory ?? new DefaultAdbClientFactory(); + var deviceHostFactory = dependencies.DeviceHostFactory ?? new DefaultDeviceHostFactory( + adbClientFactory, + processRunner, + delay, + fileSystem, + timeProvider, + environment, + idGenerator); + var viewProfileStore = dependencies.ViewProfileStore ?? new JsonViewProfileStore(fileSystem, environment); + var profileCoordinator = new ViewProfileCoordinator(viewProfileStore); + + return new( + timeProvider, + fileSystem, + processRunner, + delay, + console, + environment, + idGenerator, + adbClientFactory, + profileCoordinator, + new DeviceHostLauncher(deviceHostFactory, environment)); + } +} + +internal sealed record AppInfrastructureComposition( + TimeProvider TimeProvider, + IFileSystem FileSystem, + IProcessRunner ProcessRunner, + IDelay Delay, + IConsoleIo Console, + IEnvironmentVariables Environment, + IUniqueIdGenerator IdGenerator, + IAdbClientFactory AdbClientFactory, + ViewProfileCoordinator ProfileCoordinator, + DeviceHostLauncher DeviceHostLauncher); \ No newline at end of file diff --git a/Luotsi.Cli/Cli/AppViewCommandCompositionBuilder.cs b/Luotsi.Cli/Cli/AppViewCommandCompositionBuilder.cs new file mode 100644 index 0000000..87808b5 --- /dev/null +++ b/Luotsi.Cli/Cli/AppViewCommandCompositionBuilder.cs @@ -0,0 +1,64 @@ +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.View.Contracts; +using Luotsi.Cli.View.Diagnostics; +using Luotsi.Cli.View.Session; + +namespace Luotsi.Cli.Cli; + +internal static class AppViewCommandCompositionBuilder +{ + public static AppViewCommandComposition Build(AppViewCommandCompositionBuilderDependencies dependencies) + { + ArgumentNullException.ThrowIfNull(dependencies); + + var overrides = dependencies.Overrides; + var resolvedViewSessionFactory = overrides.ViewSessionFactory ?? new DefaultViewSessionFactory( + dependencies.Console, + dependencies.TimeProvider, + dependencies.AdbClientFactory, + dependencies.ProcessRunner, + dependencies.Environment, + dependencies.FileSystem, + dependencies.IdGenerator); + var resolvedViewDoctorFactory = overrides.ViewDoctorFactory ?? new DefaultViewDoctorFactory( + dependencies.Environment, + dependencies.FileSystem, + dependencies.ProcessRunner); + var resolvedViewSetupFactory = overrides.ViewSetupFactory ?? new DefaultViewSetupFactory( + dependencies.Environment, + dependencies.FileSystem, + dependencies.ProcessRunner, + dependencies.AdbClientFactory, + resolvedViewDoctorFactory); + var viewDiagnosticCommandHost = new ViewDiagnosticCommandHost(new( + dependencies.Environment, + dependencies.EnvelopeWriter, + resolvedViewDoctorFactory, + resolvedViewSetupFactory)); + + return new( + new ViewSessionCommandPreparer( + dependencies.DeviceHostLauncher, + resolvedViewSessionFactory, + dependencies.ProfileCoordinator, + dependencies.Environment), + new ViewDiagnosticsLauncher(dependencies.DeviceHostLauncher, viewDiagnosticCommandHost)); + } +} + +internal sealed record AppViewCommandCompositionBuilderDependencies( + AppDependencies Overrides, + TimeProvider TimeProvider, + IConsoleIo Console, + IEnvironmentVariables Environment, + IFileSystem FileSystem, + IProcessRunner ProcessRunner, + IAdbClientFactory AdbClientFactory, + IUniqueIdGenerator IdGenerator, + AppCommandEnvelopeWriter EnvelopeWriter, + ViewProfileCoordinator ProfileCoordinator, + DeviceHostLauncher DeviceHostLauncher); + +internal sealed record AppViewCommandComposition( + ViewSessionCommandPreparer ViewSessionCommandPreparer, + ViewDiagnosticsLauncher ViewDiagnosticsLauncher); \ No newline at end of file diff --git a/Luotsi.Cli/Cli/CliOptions.cs b/Luotsi.Cli/Cli/CliOptions.cs index 8a79980..1f8ce81 100644 --- a/Luotsi.Cli/Cli/CliOptions.cs +++ b/Luotsi.Cli/Cli/CliOptions.cs @@ -8,6 +8,10 @@ namespace Luotsi.Cli.Cli; /// public sealed class CliOptions { + private const string ViewCommand = "view"; + private const string ViewSetupCommand = "view-setup"; + private const string ViewSetupAlias = "setup"; + private static readonly FrozenSet KnownCommands = new[] { @@ -20,6 +24,7 @@ public sealed class CliOptions "view", "reconnect", "view-doctor", + "view-setup", "profile-list", "profile-delete", "scenario-list", @@ -66,6 +71,7 @@ public sealed class CliOptions "always-on-top", "defaults", "dry-run", + "fix", "h", "headless", "help", @@ -100,13 +106,13 @@ private CliOptions(string? command) /// Parsed options. public static CliOptions Parse(string[] args) { - var command = FindCommand(args); - var parsed = new CliOptions(command); + var commandMatch = FindCommand(args); + var parsed = new CliOptions(commandMatch.Command); for (var i = 0; i < args.Length; i++) { var token = args[i]; - if (string.Equals(token, command, StringComparison.OrdinalIgnoreCase)) + if (i == commandMatch.CommandIndex || i == commandMatch.NormalizedArgumentIndex) { continue; } @@ -130,7 +136,7 @@ public static CliOptions Parse(string[] args) return parsed; } - private static string? FindCommand(string[] args) + private static CommandMatch FindCommand(string[] args) { for (var i = 0; i < args.Length; i++) { @@ -148,11 +154,46 @@ public static CliOptions Parse(string[] args) if (KnownCommands.Contains(token)) { - return token; + if (string.Equals(token, ViewCommand, StringComparison.OrdinalIgnoreCase) + && TryFindNormalizedViewArgumentIndex(args, i, out var normalizedArgumentIndex)) + { + return new CommandMatch(ViewSetupCommand, i, normalizedArgumentIndex); + } + + return new CommandMatch(token, i, -1); + } + } + + return new CommandMatch(null, -1, -1); + } + + private static bool TryFindNormalizedViewArgumentIndex(string[] args, int commandIndex, out int normalizedArgumentIndex) + { + for (var i = commandIndex + 1; i < args.Length; i++) + { + var token = args[i]; + if (token.StartsWith("-", StringComparison.Ordinal)) + { + var key = token.TrimStart('-'); + if (!KnownFlagOptions.Contains(key) && i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + { + i++; + } + + continue; + } + + if (string.Equals(token, ViewSetupAlias, StringComparison.OrdinalIgnoreCase)) + { + normalizedArgumentIndex = i; + return true; } + + break; } - return null; + normalizedArgumentIndex = -1; + return false; } /// @@ -204,4 +245,6 @@ public int Int(string key, int defaultValue) return int.TryParse(value, out var parsed) ? parsed : throw new UsageException($"Option --{key} must be an integer."); } + + private readonly record struct CommandMatch(string? Command, int CommandIndex, int NormalizedArgumentIndex); } diff --git a/Luotsi.Cli/Cli/DeviceHostLauncher.cs b/Luotsi.Cli/Cli/DeviceHostLauncher.cs index 990a43d..8739f5b 100644 --- a/Luotsi.Cli/Cli/DeviceHostLauncher.cs +++ b/Luotsi.Cli/Cli/DeviceHostLauncher.cs @@ -1,5 +1,4 @@ using Luotsi.Cli.Artifacts; -using Luotsi.Cli.Errors; using Luotsi.Cli.Infrastructure.Contracts; namespace Luotsi.Cli.Cli; @@ -19,21 +18,7 @@ public IDeviceHost Create(CliOptions options, string adbExecutable, ArtifactSess options.Get("platform") ?? CliDefaults.DefaultPlatform, adbExecutable, deviceSelector ?? options.Get("device"), - ResolveAdbCommandTimeout(options)), + AdbCommandTimeoutResolver.Resolve(options, _environment)), artifacts); } - - private TimeSpan? ResolveAdbCommandTimeout(CliOptions options) - { - var rawValue = options.Get("adb-timeout-sec") ?? - _environment.GetEnvironmentVariable(CliDefaults.AdbCommandTimeoutEnvironmentVariable) ?? - CliDefaults.DefaultAdbCommandTimeoutSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); - - if (!int.TryParse(rawValue, out var timeoutSec) || timeoutSec < 0) - { - throw new UsageException("Option --adb-timeout-sec must be a non-negative integer."); - } - - return timeoutSec == 0 ? null : TimeSpan.FromSeconds(timeoutSec); - } } diff --git a/Luotsi.Cli/Cli/Help.cs b/Luotsi.Cli/Cli/Help.cs index 260b64b..c4f8989 100644 --- a/Luotsi.Cli/Cli/Help.cs +++ b/Luotsi.Cli/Cli/Help.cs @@ -15,6 +15,8 @@ public static class Help luotsi [options] From source: + .\scripts\luotsi.ps1 [options] + ./scripts/luotsi.sh [options] dotnet run --project Luotsi.Cli -- [options] Commands: @@ -30,8 +32,10 @@ preflight [--package ] screen-state inspect view (--device | --join-share | --last) [--profile ] [--save-profile ] [--share-bind ] [--preset safe|balanced|high-quality|low-latency] [--defaults] [--read-only] [--always-on-top] [--codec h264|h265] [--decoder ffmpeg|wmf] [--capture-backend auto|screenrecord|mediaprojection] [--headless] [--record ] [--stats-interval-ms ] [--renderer-stats-interval-ms ] + view setup --device [--profile ] [--preset safe|balanced|high-quality|low-latency] [--defaults] [--decoder ffmpeg|wmf] [--capture-backend auto|screenrecord|mediaprojection] [--dry-run] + view-setup --device [--profile ] [--preset safe|balanced|high-quality|low-latency] [--defaults] [--decoder ffmpeg|wmf] [--capture-backend auto|screenrecord|mediaprojection] [--dry-run] reconnect [--profile ] [--device | --join-share ] [--save-profile ] [--share-bind ] [--preset safe|balanced|high-quality|low-latency] [--defaults] [--read-only] [--always-on-top] [--codec h264|h265] [--decoder ffmpeg|wmf] [--capture-backend auto|screenrecord|mediaprojection] [--headless] [--record ] [--stats-interval-ms ] [--renderer-stats-interval-ms ] - view-doctor --device [--profile ] [--preset safe|balanced|high-quality|low-latency] [--defaults] [--read-only] [--decoder ffmpeg|wmf] [--capture-backend auto|screenrecord|mediaprojection] [--record ] + view-doctor --device [--profile ] [--preset safe|balanced|high-quality|low-latency] [--defaults] [--read-only] [--decoder ffmpeg|wmf] [--capture-backend auto|screenrecord|mediaprojection] [--record ] [--fix] profile-list profile-delete --name wireless --device [--host ] [--port 5555] diff --git a/Luotsi.Cli/Cli/ViewCommandOptionsFactory.cs b/Luotsi.Cli/Cli/ViewCommandOptionsFactory.cs index a9d0775..007a6fd 100644 --- a/Luotsi.Cli/Cli/ViewCommandOptionsFactory.cs +++ b/Luotsi.Cli/Cli/ViewCommandOptionsFactory.cs @@ -5,7 +5,7 @@ namespace Luotsi.Cli.Cli; internal static class ViewCommandOptionsFactory { - public static ViewOptions Build(CliOptions options, string adbExecutable, bool allowJoinShare) + public static ViewOptions Build(CliOptions options, string adbExecutable, bool allowJoinShare, TimeSpan? commandTimeout) { var joinShareEndpoint = options.Get("join-share"); if (!allowJoinShare && !string.IsNullOrWhiteSpace(joinShareEndpoint)) @@ -59,7 +59,8 @@ public static ViewOptions Build(CliOptions options, string adbExecutable, bool a joinShareEndpoint, options.HasFlag("always-on-top"), scaleMode, - captureBackend); + captureBackend, + commandTimeout); } private static int GetIntOrDefault(CliOptions options, string key, int defaultValue) => diff --git a/Luotsi.Cli/Cli/ViewDiagnosticCommandHost.cs b/Luotsi.Cli/Cli/ViewDiagnosticCommandHost.cs new file mode 100644 index 0000000..69fcbdf --- /dev/null +++ b/Luotsi.Cli/Cli/ViewDiagnosticCommandHost.cs @@ -0,0 +1,42 @@ +using Luotsi.Cli.Artifacts; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.View.Diagnostics; + +namespace Luotsi.Cli.Cli; + +internal sealed class ViewDiagnosticCommandHost(ViewDiagnosticCommandHostDependencies dependencies) +{ + private readonly ViewDiagnosticCommandHostDependencies _dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies)); + + public async Task RunAsync(ViewDiagnosticInvocation command, CliOptions options, DateTimeOffset started, string adbExecutable, IDeviceHost runner, ArtifactSession artifacts) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(runner); + ArgumentNullException.ThrowIfNull(artifacts); + + var viewOptions = BuildViewOptions(options, adbExecutable); + if (command.Action == ViewDiagnosticAction.Setup) + { + var setup = await _dependencies.ViewSetupFactory.Create(runner).SetupAsync(viewOptions, command.Fix).ConfigureAwait(false); + _dependencies.EnvelopeWriter.WriteSuccess(command.EnvelopeCommand, started, setup, artifacts.ToData()); + return setup.Ready ? 0 : 1; + } + + var report = await _dependencies.ViewDoctorFactory.Create(runner).DiagnoseAsync(viewOptions).ConfigureAwait(false); + _dependencies.EnvelopeWriter.WriteSuccess(command.EnvelopeCommand, started, report, artifacts.ToData()); + return 0; + } + + private View.Contracts.ViewOptions BuildViewOptions(CliOptions options, string adbExecutable) + { + var commandTimeout = AdbCommandTimeoutResolver.Resolve(options, _dependencies.Environment); + return ViewCommandOptionsFactory.Build(options, adbExecutable, allowJoinShare: false, commandTimeout); + } +} + +internal sealed record ViewDiagnosticCommandHostDependencies( + IEnvironmentVariables Environment, + AppCommandEnvelopeWriter EnvelopeWriter, + IViewDoctorFactory ViewDoctorFactory, + IViewSetupFactory ViewSetupFactory); diff --git a/Luotsi.Cli/Cli/ViewDiagnosticInvocation.cs b/Luotsi.Cli/Cli/ViewDiagnosticInvocation.cs new file mode 100644 index 0000000..fc2e2d3 --- /dev/null +++ b/Luotsi.Cli/Cli/ViewDiagnosticInvocation.cs @@ -0,0 +1,42 @@ +namespace Luotsi.Cli.Cli; + +internal enum ViewDiagnosticAction +{ + Doctor, + Setup +} + +internal sealed class ViewDiagnosticInvocation +{ + private ViewDiagnosticInvocation(ViewDiagnosticAction action, string envelopeCommand, bool fix) + { + Action = action; + EnvelopeCommand = envelopeCommand; + Fix = fix; + } + + public ViewDiagnosticAction Action { get; } + + public string EnvelopeCommand { get; } + + public bool Fix { get; } + + public static ViewDiagnosticInvocation? Resolve(CliOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (string.Equals(options.Command, "view-setup", StringComparison.OrdinalIgnoreCase)) + { + return new ViewDiagnosticInvocation(ViewDiagnosticAction.Setup, "view-setup", fix: !options.HasFlag("dry-run")); + } + + if (string.Equals(options.Command, "view-doctor", StringComparison.OrdinalIgnoreCase)) + { + return options.HasFlag("fix") + ? new ViewDiagnosticInvocation(ViewDiagnosticAction.Setup, "view-doctor", fix: true) + : new ViewDiagnosticInvocation(ViewDiagnosticAction.Doctor, "view-doctor", fix: false); + } + + return null; + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Cli/ViewDoctorLauncher.cs b/Luotsi.Cli/Cli/ViewDiagnosticsLauncher.cs similarity index 54% rename from Luotsi.Cli/Cli/ViewDoctorLauncher.cs rename to Luotsi.Cli/Cli/ViewDiagnosticsLauncher.cs index 1282069..c7cbac5 100644 --- a/Luotsi.Cli/Cli/ViewDoctorLauncher.cs +++ b/Luotsi.Cli/Cli/ViewDiagnosticsLauncher.cs @@ -3,22 +3,23 @@ namespace Luotsi.Cli.Cli; -internal sealed class ViewDoctorLauncher( +internal sealed class ViewDiagnosticsLauncher( DeviceHostLauncher deviceHostLauncher, - AppCommandHost commandHost) + ViewDiagnosticCommandHost commandHost) { private readonly DeviceHostLauncher _deviceHostLauncher = deviceHostLauncher ?? throw new ArgumentNullException(nameof(deviceHostLauncher)); - private readonly AppCommandHost _commandHost = commandHost ?? throw new ArgumentNullException(nameof(commandHost)); + private readonly ViewDiagnosticCommandHost _commandHost = commandHost ?? throw new ArgumentNullException(nameof(commandHost)); - public PreparedHostedCommand Prepare(CliOptions options, DateTimeOffset started, string adbExecutable, ArtifactSession artifacts) + public PreparedHostedCommand Prepare(ViewDiagnosticInvocation command, CliOptions options, DateTimeOffset started, string adbExecutable, ArtifactSession artifacts) { + ArgumentNullException.ThrowIfNull(command); ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(artifacts); var runner = _deviceHostLauncher.Create(options, adbExecutable, artifacts); return new PreparedHostedCommand( runner, - () => _commandHost.RunViewDoctorAsync(options, started, adbExecutable, runner, artifacts)); + () => _commandHost.RunAsync(command, options, started, adbExecutable, runner, artifacts)); } } diff --git a/Luotsi.Cli/Cli/ViewProfileCoordinator.cs b/Luotsi.Cli/Cli/ViewProfileCoordinator.cs index a1ed7ff..621c762 100644 --- a/Luotsi.Cli/Cli/ViewProfileCoordinator.cs +++ b/Luotsi.Cli/Cli/ViewProfileCoordinator.cs @@ -23,9 +23,10 @@ public async Task ApplyDefaultsAsync(CliOptions options) if (!string.Equals(options.Command, "view", StringComparison.OrdinalIgnoreCase) && !string.Equals(options.Command, "reconnect", StringComparison.OrdinalIgnoreCase) && - !string.Equals(options.Command, "view-doctor", StringComparison.OrdinalIgnoreCase)) + !string.Equals(options.Command, "view-doctor", StringComparison.OrdinalIgnoreCase) && + !string.Equals(options.Command, "view-setup", StringComparison.OrdinalIgnoreCase)) { - throw new UsageException("--profile is only supported for view, reconnect, and view-doctor."); + throw new UsageException("--profile is only supported for view, reconnect, view-doctor, and view-setup."); } var profile = await _viewProfileStore.LoadAsync(profileName).ConfigureAwait(false) diff --git a/Luotsi.Cli/Cli/ViewSessionCommandPreparer.cs b/Luotsi.Cli/Cli/ViewSessionCommandPreparer.cs index d095874..43ec1f3 100644 --- a/Luotsi.Cli/Cli/ViewSessionCommandPreparer.cs +++ b/Luotsi.Cli/Cli/ViewSessionCommandPreparer.cs @@ -8,15 +8,18 @@ namespace Luotsi.Cli.Cli; internal sealed class ViewSessionCommandPreparer( DeviceHostLauncher deviceHostLauncher, IViewSessionFactory viewSessionFactory, - ViewProfileCoordinator profileCoordinator) + ViewProfileCoordinator profileCoordinator, + IEnvironmentVariables environment) { private readonly DeviceHostLauncher _deviceHostLauncher = deviceHostLauncher ?? throw new ArgumentNullException(nameof(deviceHostLauncher)); private readonly IViewSessionFactory _viewSessionFactory = viewSessionFactory ?? throw new ArgumentNullException(nameof(viewSessionFactory)); private readonly ViewProfileCoordinator _profileCoordinator = profileCoordinator ?? throw new ArgumentNullException(nameof(profileCoordinator)); + private readonly IEnvironmentVariables _environment = environment ?? throw new ArgumentNullException(nameof(environment)); public async Task PrepareAsync(CliOptions options, string adbExecutable, ArtifactSession artifacts) { - var viewOptions = ViewCommandOptionsFactory.Build(options, adbExecutable, allowJoinShare: true); + var commandTimeout = AdbCommandTimeoutResolver.Resolve(options, _environment); + var viewOptions = ViewCommandOptionsFactory.Build(options, adbExecutable, allowJoinShare: true, commandTimeout); await _profileCoordinator.SaveIfRequestedAsync(options, viewOptions).ConfigureAwait(false); var runner = string.IsNullOrWhiteSpace(viewOptions.JoinShareEndpoint) ? _deviceHostLauncher.Create(options, adbExecutable, artifacts, viewOptions.DeviceSelector) diff --git a/Luotsi.Cli/Hosts/Android/AndroidDeviceControlOperations.cs b/Luotsi.Cli/Hosts/Android/AndroidDeviceControlOperations.cs index ebffb67..9e24d80 100644 --- a/Luotsi.Cli/Hosts/Android/AndroidDeviceControlOperations.cs +++ b/Luotsi.Cli/Hosts/Android/AndroidDeviceControlOperations.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text.RegularExpressions; using Luotsi.Cli.Errors; using Luotsi.Cli.Infrastructure.Contracts; @@ -19,170 +18,6 @@ internal sealed class AndroidDeviceControlOperations( private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); private readonly Action _invalidateUiReadCaches = invalidateUiReadCaches ?? throw new ArgumentNullException(nameof(invalidateUiReadCaches)); - public async Task PushFileAsync(string localPath, string? remoteDirectory = null) - { - var validatedLocalPath = Path.GetFullPath(RequireNonBlank(localPath, "push file requires a local path.")); - if (!_fileSystem.FileExists(validatedLocalPath)) - { - throw new FileNotFoundException($"Host file '{validatedLocalPath}' was not found.", validatedLocalPath); - } - - var targetDirectory = NormalizeDeviceDirectoryForPush(remoteDirectory); - var remotePath = $"{targetDirectory}/{Path.GetFileName(validatedLocalPath)}"; - var result = await _adb.RunAsync(["push", validatedLocalPath, remotePath]).ConfigureAwait(false); - result.EnsureSuccess("push file failed"); - return new PushFileResult(validatedLocalPath, remotePath); - } - - public async Task PullFileAsync(string remotePath, string? localDirectory = null) - { - var validatedRemotePath = RequireNonBlank(remotePath, "pull file requires a remote path."); - var targetDirectory = string.IsNullOrWhiteSpace(localDirectory) - ? Directory.GetCurrentDirectory() - : Path.GetFullPath(localDirectory); - _fileSystem.CreateDirectory(targetDirectory); - var remoteFileName = Path.GetFileName(validatedRemotePath.TrimEnd('/')); - var safeRemoteFileName = Path.GetFileName(remoteFileName); - if (string.IsNullOrWhiteSpace(safeRemoteFileName) || Path.IsPathRooted(safeRemoteFileName)) - { - throw new InvalidOperationException($"Remote path '{validatedRemotePath}' does not contain a valid file name."); - } - - var localPath = Path.Combine(targetDirectory, safeRemoteFileName); - var result = await _adb.RunAsync(["pull", validatedRemotePath, localPath]).ConfigureAwait(false); - result.EnsureSuccess("pull file failed"); - return new PullFileResult(validatedRemotePath, localPath); - } - - public async Task ListForwardsAsync() - { - var result = await _adb.RunAsync(["forward", "--list"]).ConfigureAwait(false); - result.EnsureSuccess("adb forward --list failed"); - return new PortForwardListResult(ParseForwardEntries(result.Stdout)); - } - - public async Task ForwardAsync(string local, string remote, bool noRebind) - { - var validatedLocal = RequirePortSpec(local, "forward requires a local endpoint."); - var validatedRemote = RequirePortSpec(remote, "forward requires a remote endpoint."); - string[] args = noRebind - ? ["forward", "--no-rebind", validatedLocal, validatedRemote] - : ["forward", validatedLocal, validatedRemote]; - var result = await _adb.RunAsync(args).ConfigureAwait(false); - result.EnsureSuccess("adb forward failed"); - return new PortForwardResult(validatedLocal, validatedRemote, noRebind); - } - - public async Task RemoveForwardAsync(string local) - { - var validatedLocal = RequirePortSpec(local, "forward-remove requires a local endpoint."); - var result = await _adb.RunAsync(["forward", "--remove", validatedLocal]).ConfigureAwait(false); - result.EnsureSuccess("adb forward --remove failed"); - return new PortForwardRemoveResult(validatedLocal); - } - - public async Task ListReversesAsync() - { - var result = await _adb.RunAsync(["reverse", "--list"]).ConfigureAwait(false); - result.EnsureSuccess("adb reverse --list failed"); - return new PortReverseListResult(ParseReverseEntries(result.Stdout)); - } - - public async Task ReverseAsync(string remote, string local, bool noRebind) - { - var validatedRemote = RequirePortSpec(remote, "reverse requires a remote endpoint."); - var validatedLocal = RequirePortSpec(local, "reverse requires a local endpoint."); - string[] args = noRebind - ? ["reverse", "--no-rebind", validatedRemote, validatedLocal] - : ["reverse", validatedRemote, validatedLocal]; - var result = await _adb.RunAsync(args).ConfigureAwait(false); - result.EnsureSuccess("adb reverse failed"); - return new PortReverseResult(validatedRemote, validatedLocal, noRebind); - } - - public async Task RemoveReverseAsync(string remote) - { - var validatedRemote = RequirePortSpec(remote, "reverse-remove requires a remote endpoint."); - var result = await _adb.RunAsync(["reverse", "--remove", validatedRemote]).ConfigureAwait(false); - result.EnsureSuccess("adb reverse --remove failed"); - return new PortReverseRemoveResult(validatedRemote); - } - - public async Task EnableWirelessAsync(string? host, int port) - { - if (port <= 0 || port > 65535) - { - throw new UsageException("wireless requires --port between 1 and 65535."); - } - - var validatedHost = string.IsNullOrWhiteSpace(host) - ? await DetectWirelessHostAsync().ConfigureAwait(false) - : host.Trim(); - var tcpip = await _adb.RunAsync(["tcpip", port.ToString(CultureInfo.InvariantCulture)]).ConfigureAwait(false); - tcpip.EnsureSuccess("adb tcpip failed"); - var endpoint = $"{validatedHost}:{port}"; - var connect = await _adb.RunAsync(["connect", endpoint]).ConfigureAwait(false); - connect.EnsureSuccess("adb connect failed"); - return new WirelessConnectResult(validatedHost, port, endpoint); - } - - public async Task ScanWirelessServicesAsync() - { - var result = await _adb.RunAsync(["mdns", "services"]).ConfigureAwait(false); - result.EnsureSuccess("adb mdns services failed"); - return WirelessDebugResolver.CreateScanResult(ParseWirelessMdnsServices(result.Stdout)); - } - - public async Task PairWirelessAsync(string? endpoint, string? service, string? pairingCode) - { - var target = await ResolvePairingServiceAsync(endpoint, service).ConfigureAwait(false); - var normalizedCode = string.IsNullOrWhiteSpace(pairingCode) ? null : pairingCode.Trim(); - if (normalizedCode is null) - { - return new WirelessPairResult( - target.Endpoint, - target.ServiceName, - target.ServiceType, - target.Selector, - Paired: false, - InteractiveRequired: true, - $"Luotsi cannot drive adb's interactive pairing prompt while preserving one JSON command envelope. Pass --code , or run `adb pair {target.Endpoint}` manually.", - Stdout: null); - } - - var result = await _adb.RunAsync(["pair", target.Endpoint, normalizedCode]).ConfigureAwait(false); - result.EnsureSuccess("adb pair failed"); - var stdout = result.Stdout.Trim(); - return new WirelessPairResult( - target.Endpoint, - target.ServiceName, - target.ServiceType, - target.Selector, - Paired: true, - InteractiveRequired: false, - string.IsNullOrWhiteSpace(stdout) ? $"Paired to {target.Endpoint}." : stdout, - string.IsNullOrWhiteSpace(stdout) ? null : stdout); - } - - public async Task ConnectWirelessAsync(string? endpoint, string? service) - { - var target = await ResolveConnectServiceAsync(endpoint, service).ConfigureAwait(false); - var connectTarget = target.Endpoint; - var result = await _adb.RunAsync(["connect", connectTarget]).ConfigureAwait(false); - result.EnsureSuccess("adb connect failed"); - var stdout = result.Stdout.Trim(); - return new WirelessMdnsConnectResult( - target.Endpoint, - target.ServiceName, - target.ServiceType, - target.Selector, - connectTarget, - target.Selector ?? target.Endpoint, - Connected: true, - string.IsNullOrWhiteSpace(stdout) ? $"Connected to {connectTarget}." : stdout, - string.IsNullOrWhiteSpace(stdout) ? null : stdout); - } - public async Task InstallPackageAsync(string packagePath) { var validatedPackagePath = Path.GetFullPath(RequireNonBlank(packagePath, "install package requires a local path.")); @@ -347,63 +182,6 @@ public async Task RevokePermissionAsync(string packageN return new PermissionCommandResult(validatedPackage, validatedPermission); } - internal static IReadOnlyList ParseWirelessMdnsServices(string output) => - WirelessDebugResolver.ParseMdnsServices(output); - - internal static string? ParseRouteSourceAddress(string output) - { - if (string.IsNullOrWhiteSpace(output)) - { - return null; - } - - var tokens = output.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - for (var index = 0; index < tokens.Length - 1; index++) - { - if (string.Equals(tokens[index], "src", StringComparison.OrdinalIgnoreCase)) - { - return tokens[index + 1]; - } - } - - return null; - } - - private async Task ResolvePairingServiceAsync(string? endpoint, string? service) - { - if (!string.IsNullOrWhiteSpace(endpoint)) - { - return WirelessDebugResolver.ResolvePairingService([], endpoint, service); - } - - var scan = await ScanWirelessServicesAsync().ConfigureAwait(false); - return WirelessDebugResolver.ResolvePairingService(scan.PairingServices, endpoint, service); - } - - private async Task ResolveConnectServiceAsync(string? endpoint, string? service) - { - if (!string.IsNullOrWhiteSpace(endpoint)) - { - return WirelessDebugResolver.ResolveConnectService(new WirelessScanResult([], [], [], []), endpoint, service); - } - - var scan = await ScanWirelessServicesAsync().ConfigureAwait(false); - return WirelessDebugResolver.ResolveConnectService(scan, endpoint, service); - } - - private async Task DetectWirelessHostAsync() - { - var route = await _adb.ShellAsync("ip route get 8.8.8.8").ConfigureAwait(false); - route.EnsureSuccess("wireless host auto-detection failed"); - var sourceAddress = ParseRouteSourceAddress(route.Stdout); - if (string.IsNullOrWhiteSpace(sourceAddress)) - { - throw new UsageException("wireless could not auto-detect the device Wi-Fi IP address. Pass --host ."); - } - - return sourceAddress; - } - private async Task WaitForActivityStateAsync(string activity, int timeoutSec, bool shouldMatch) { var deadline = _timeProvider.GetUtcNow().AddSeconds(timeoutSec); @@ -472,31 +250,6 @@ private static bool IndicatesMissingPackage(AdbCommandResult result) output.Contains("unknown package", StringComparison.OrdinalIgnoreCase); } - private static IReadOnlyList ParseForwardEntries(string output) => - output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(static line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) - .Where(static parts => parts.Length >= 3) - .Select(static parts => new PortForwardEntry(parts[0], parts[1], parts[2])) - .ToArray(); - - private static IReadOnlyList ParseReverseEntries(string output) => - output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(static line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) - .Where(static parts => parts.Length >= 3) - .Select(static parts => new PortReverseEntry(parts[0], parts[1], parts[2])) - .ToArray(); - - private static string RequirePortSpec(string value, string message) - { - var trimmed = RequireNonBlank(value, message).Trim(); - if (trimmed.Any(char.IsWhiteSpace) || !trimmed.Contains(':', StringComparison.Ordinal)) - { - throw new UsageException($"{message} Use adb endpoint syntax such as tcp:8080 or localabstract:name."); - } - - return trimmed; - } - private static string? NormalizeOptional(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); @@ -507,23 +260,6 @@ private async Task ShellTextAsync(string command) return result.Stdout.Trim(); } - private static string NormalizeDeviceDirectoryForPush(string? path) - { - var normalized = string.IsNullOrWhiteSpace(path) ? "/sdcard/Download" : path.Replace('\\', '/').Trim(); - normalized = normalized.Replace("\r", string.Empty, StringComparison.Ordinal).Replace("\n", string.Empty, StringComparison.Ordinal).TrimEnd('/'); - if (!normalized.StartsWith("/", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"Device directory '{path}' must be absolute for adb push."); - } - - if (normalized.Contains("/../", StringComparison.Ordinal) || normalized.EndsWith("/..", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"Device directory '{path}' contains unsupported parent traversal."); - } - - return normalized; - } - private static string RequireNonBlank(string value, string message) { if (string.IsNullOrWhiteSpace(value)) diff --git a/Luotsi.Cli/Hosts/Android/AndroidFileAndPortOperations.cs b/Luotsi.Cli/Hosts/Android/AndroidFileAndPortOperations.cs new file mode 100644 index 0000000..378b1eb --- /dev/null +++ b/Luotsi.Cli/Hosts/Android/AndroidFileAndPortOperations.cs @@ -0,0 +1,157 @@ +using Luotsi.Cli.Errors; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Models; + +namespace Luotsi.Cli.Hosts.Android; + +internal sealed class AndroidFileAndPortOperations( + IAdbClient adb, + IFileSystem fileSystem) +{ + private readonly IAdbClient _adb = adb ?? throw new ArgumentNullException(nameof(adb)); + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + + public async Task PushFileAsync(string localPath, string? remoteDirectory = null) + { + var validatedLocalPath = Path.GetFullPath(RequireNonBlank(localPath, "push file requires a local path.")); + if (!_fileSystem.FileExists(validatedLocalPath)) + { + throw new FileNotFoundException($"Host file '{validatedLocalPath}' was not found.", validatedLocalPath); + } + + var targetDirectory = NormalizeDeviceDirectoryForPush(remoteDirectory); + var remotePath = $"{targetDirectory}/{Path.GetFileName(validatedLocalPath)}"; + var result = await _adb.RunAsync(["push", validatedLocalPath, remotePath]).ConfigureAwait(false); + result.EnsureSuccess("push file failed"); + return new PushFileResult(validatedLocalPath, remotePath); + } + + public async Task PullFileAsync(string remotePath, string? localDirectory = null) + { + var validatedRemotePath = RequireNonBlank(remotePath, "pull file requires a remote path."); + var targetDirectory = string.IsNullOrWhiteSpace(localDirectory) + ? Directory.GetCurrentDirectory() + : Path.GetFullPath(localDirectory); + _fileSystem.CreateDirectory(targetDirectory); + var remoteFileName = Path.GetFileName(validatedRemotePath.TrimEnd('/')); + var safeRemoteFileName = Path.GetFileName(remoteFileName); + if (string.IsNullOrWhiteSpace(safeRemoteFileName) || Path.IsPathRooted(safeRemoteFileName)) + { + throw new InvalidOperationException($"Remote path '{validatedRemotePath}' does not contain a valid file name."); + } + + var normalizedTargetDirectory = targetDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var localPath = string.IsNullOrEmpty(normalizedTargetDirectory) + ? safeRemoteFileName + : normalizedTargetDirectory + Path.DirectorySeparatorChar + safeRemoteFileName; + var result = await _adb.RunAsync(["pull", validatedRemotePath, localPath]).ConfigureAwait(false); + result.EnsureSuccess("pull file failed"); + return new PullFileResult(validatedRemotePath, localPath); + } + + public async Task ListForwardsAsync() + { + var result = await _adb.RunAsync(["forward", "--list"]).ConfigureAwait(false); + result.EnsureSuccess("adb forward --list failed"); + return new PortForwardListResult(ParseForwardEntries(result.Stdout)); + } + + public async Task ForwardAsync(string local, string remote, bool noRebind) + { + var validatedLocal = RequirePortSpec(local, "forward requires a local endpoint."); + var validatedRemote = RequirePortSpec(remote, "forward requires a remote endpoint."); + string[] args = noRebind + ? ["forward", "--no-rebind", validatedLocal, validatedRemote] + : ["forward", validatedLocal, validatedRemote]; + var result = await _adb.RunAsync(args).ConfigureAwait(false); + result.EnsureSuccess("adb forward failed"); + return new PortForwardResult(validatedLocal, validatedRemote, noRebind); + } + + public async Task RemoveForwardAsync(string local) + { + var validatedLocal = RequirePortSpec(local, "forward-remove requires a local endpoint."); + var result = await _adb.RunAsync(["forward", "--remove", validatedLocal]).ConfigureAwait(false); + result.EnsureSuccess("adb forward --remove failed"); + return new PortForwardRemoveResult(validatedLocal); + } + + public async Task ListReversesAsync() + { + var result = await _adb.RunAsync(["reverse", "--list"]).ConfigureAwait(false); + result.EnsureSuccess("adb reverse --list failed"); + return new PortReverseListResult(ParseReverseEntries(result.Stdout)); + } + + public async Task ReverseAsync(string remote, string local, bool noRebind) + { + var validatedRemote = RequirePortSpec(remote, "reverse requires a remote endpoint."); + var validatedLocal = RequirePortSpec(local, "reverse requires a local endpoint."); + string[] args = noRebind + ? ["reverse", "--no-rebind", validatedRemote, validatedLocal] + : ["reverse", validatedRemote, validatedLocal]; + var result = await _adb.RunAsync(args).ConfigureAwait(false); + result.EnsureSuccess("adb reverse failed"); + return new PortReverseResult(validatedRemote, validatedLocal, noRebind); + } + + public async Task RemoveReverseAsync(string remote) + { + var validatedRemote = RequirePortSpec(remote, "reverse-remove requires a remote endpoint."); + var result = await _adb.RunAsync(["reverse", "--remove", validatedRemote]).ConfigureAwait(false); + result.EnsureSuccess("adb reverse --remove failed"); + return new PortReverseRemoveResult(validatedRemote); + } + + private static IReadOnlyList ParseForwardEntries(string output) => + output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Where(static parts => parts.Length >= 3) + .Select(static parts => new PortForwardEntry(parts[0], parts[1], parts[2])) + .ToArray(); + + private static IReadOnlyList ParseReverseEntries(string output) => + output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Where(static parts => parts.Length >= 3) + .Select(static parts => new PortReverseEntry(parts[0], parts[1], parts[2])) + .ToArray(); + + private static string RequirePortSpec(string value, string message) + { + var trimmed = RequireNonBlank(value, message).Trim(); + if (trimmed.Any(char.IsWhiteSpace) || !trimmed.Contains(':', StringComparison.Ordinal)) + { + throw new UsageException($"{message} Use adb endpoint syntax such as tcp:8080 or localabstract:name."); + } + + return trimmed; + } + + private static string NormalizeDeviceDirectoryForPush(string? path) + { + var normalized = string.IsNullOrWhiteSpace(path) ? "/sdcard/Download" : path.Replace('\\', '/').Trim(); + normalized = normalized.Replace("\r", string.Empty, StringComparison.Ordinal).Replace("\n", string.Empty, StringComparison.Ordinal).TrimEnd('/'); + if (!normalized.StartsWith("/", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Device directory '{path}' must be absolute for adb push."); + } + + if (normalized.Contains("/../", StringComparison.Ordinal) || normalized.EndsWith("/..", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Device directory '{path}' contains unsupported parent traversal."); + } + + return normalized; + } + + private static string RequireNonBlank(string value, string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new UsageException(message); + } + + return value; + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Hosts/Android/AndroidLogAndTelemetryOperations.cs b/Luotsi.Cli/Hosts/Android/AndroidLogMonitorOperations.cs similarity index 56% rename from Luotsi.Cli/Hosts/Android/AndroidLogAndTelemetryOperations.cs rename to Luotsi.Cli/Hosts/Android/AndroidLogMonitorOperations.cs index d10b426..9e0af13 100644 --- a/Luotsi.Cli/Hosts/Android/AndroidLogAndTelemetryOperations.cs +++ b/Luotsi.Cli/Hosts/Android/AndroidLogMonitorOperations.cs @@ -6,16 +6,14 @@ namespace Luotsi.Cli.Hosts.Android; -internal sealed class AndroidLogAndTelemetryOperations( +internal sealed class AndroidLogMonitorOperations( IAdbClient adb, ArtifactSession artifacts, - TimeProvider timeProvider, - AndroidTelemetryMonitor telemetryMonitor) + TimeProvider timeProvider) { private readonly IAdbClient _adb = adb ?? throw new ArgumentNullException(nameof(adb)); private readonly ArtifactSession _artifacts = artifacts ?? throw new ArgumentNullException(nameof(artifacts)); private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - private readonly AndroidTelemetryMonitor _telemetryMonitor = telemetryMonitor ?? throw new ArgumentNullException(nameof(telemetryMonitor)); public async Task WaitForLogAsync(string text, int timeoutSec) { @@ -59,83 +57,6 @@ public async Task LogcatAsync(int tail) return new LogcatResult(result.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)); } - public async Task TelemetryTailAsync(int tail) - { - var validatedTail = RequirePositive(tail, "telemetryTail requires tail greater than zero."); - var result = await _adb.RunAsync(["logcat", "-d", "-v", "brief", "-t", validatedTail.ToString()]).ConfigureAwait(false); - result.EnsureSuccess("telemetry tail failed"); - return await _telemetryMonitor.CaptureTelemetryAsync( - DeviceArtifactNames.TelemetryTailBaseName, - result.Stdout, - new - { - schema = ResultSchemas.TelemetryTail, - tail = validatedTail, - invocation = result.Invocation - }).ConfigureAwait(false); - } - - public async Task TelemetryWatchAsync(int timeoutSec) - { - var validatedTimeoutSec = RequirePositive(timeoutSec, "telemetryWatch requires timeoutSec greater than zero."); - var telemetrySession = await _telemetryMonitor.MonitorTelemetryAsync(validatedTimeoutSec).ConfigureAwait(false); - return await _telemetryMonitor.CaptureTelemetryAsync( - DeviceArtifactNames.TelemetryWatchBaseName, - telemetrySession.LogOutput, - new - { - schema = ResultSchemas.TelemetryWatch, - started_at = telemetrySession.StartedAt, - timeout_sec = validatedTimeoutSec, - invocation = telemetrySession.Invocation - }, - telemetrySession.Parsed).ConfigureAwait(false); - } - - public Task WaitForStepAsync(string step, int timeoutSec) - { - var expectedStep = NormalizeTelemetryStep(RequireNonBlank(step, "waitStep requires step.")); - var validatedTimeoutSec = RequirePositive(timeoutSec, "waitStep requires timeoutSec greater than zero."); - return _telemetryMonitor.WaitForTelemetryEventAsync( - validatedTimeoutSec, - telemetry => string.Equals(telemetry.Event, "step", StringComparison.OrdinalIgnoreCase) - && string.Equals(NormalizeTelemetryStep(telemetry.Step), expectedStep, StringComparison.Ordinal), - telemetry => new TelemetryMatchResult(expectedStep, null, telemetry.RawLine, telemetry.Event!, telemetry.Payload), - DeviceArtifactNames.WaitStepBaseName, - invocation => new - { - schema = ResultSchemas.WaitStep, - step = expectedStep, - timeout_sec = validatedTimeoutSec, - invocation - }, - () => new SemanticWaitTimeoutException($"device step '{expectedStep}'", validatedTimeoutSec)); - } - - public Task WaitForActionReadyAsync(string action, string? step, int timeoutSec) - { - var expectedAction = RequireNonBlank(action, "waitActionReady requires action."); - var normalizedStep = NormalizeTelemetryStep(step); - var validatedTimeoutSec = RequirePositive(timeoutSec, "waitActionReady requires timeoutSec greater than zero."); - return _telemetryMonitor.WaitForTelemetryEventAsync( - validatedTimeoutSec, - telemetry => - string.Equals(telemetry.Event, "action_ready", StringComparison.OrdinalIgnoreCase) && - string.Equals(telemetry.Action, expectedAction, StringComparison.OrdinalIgnoreCase) && - (normalizedStep is null || string.Equals(NormalizeTelemetryStep(telemetry.Step), normalizedStep, StringComparison.Ordinal)), - telemetry => new TelemetryMatchResult(normalizedStep, expectedAction, telemetry.RawLine, telemetry.Event!, telemetry.Payload), - DeviceArtifactNames.WaitActionReadyBaseName, - invocation => new - { - schema = ResultSchemas.WaitActionReady, - action = expectedAction, - step = normalizedStep, - timeout_sec = validatedTimeoutSec, - invocation - }, - () => new SemanticWaitTimeoutException($"device action ready '{expectedAction}'" + (normalizedStep is null ? string.Empty : $" on '{normalizedStep}'"), validatedTimeoutSec)); - } - public async Task ResetLogAsync() { var result = await _adb.RunAsync(["logcat", "-c"]).ConfigureAwait(false); @@ -234,15 +155,4 @@ private static int RequirePositive(int value, string message) throw new UsageException($"assertEvent detailsPattern is not a valid regular expression: {ex.Message}"); } } - - private static string? NormalizeTelemetryStep(string? step) - { - if (string.IsNullOrWhiteSpace(step)) - { - return null; - } - - var normalized = step.Trim().ToUpperInvariant().Replace('-', '_'); - return normalized.StartsWith("STEP_", StringComparison.Ordinal) ? normalized : $"STEP_{normalized}"; - } } \ No newline at end of file diff --git a/Luotsi.Cli/Hosts/Android/AndroidSemanticTelemetryOperations.cs b/Luotsi.Cli/Hosts/Android/AndroidSemanticTelemetryOperations.cs new file mode 100644 index 0000000..1927a57 --- /dev/null +++ b/Luotsi.Cli/Hosts/Android/AndroidSemanticTelemetryOperations.cs @@ -0,0 +1,122 @@ +using Luotsi.Cli.Artifacts; +using Luotsi.Cli.Errors; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Models; + +namespace Luotsi.Cli.Hosts.Android; + +internal sealed class AndroidSemanticTelemetryOperations( + IAdbClient adb, + AndroidTelemetryMonitor telemetryMonitor) +{ + private readonly IAdbClient _adb = adb ?? throw new ArgumentNullException(nameof(adb)); + private readonly AndroidTelemetryMonitor _telemetryMonitor = telemetryMonitor ?? throw new ArgumentNullException(nameof(telemetryMonitor)); + + public async Task TelemetryTailAsync(int tail) + { + var validatedTail = RequirePositive(tail, "telemetryTail requires tail greater than zero."); + var result = await _adb.RunAsync(["logcat", "-d", "-v", "brief", "-t", validatedTail.ToString()]).ConfigureAwait(false); + result.EnsureSuccess("telemetry tail failed"); + return await _telemetryMonitor.CaptureTelemetryAsync( + DeviceArtifactNames.TelemetryTailBaseName, + result.Stdout, + new + { + schema = ResultSchemas.TelemetryTail, + tail = validatedTail, + invocation = result.Invocation + }).ConfigureAwait(false); + } + + public async Task TelemetryWatchAsync(int timeoutSec) + { + var validatedTimeoutSec = RequirePositive(timeoutSec, "telemetryWatch requires timeoutSec greater than zero."); + var telemetrySession = await _telemetryMonitor.MonitorTelemetryAsync(validatedTimeoutSec).ConfigureAwait(false); + return await _telemetryMonitor.CaptureTelemetryAsync( + DeviceArtifactNames.TelemetryWatchBaseName, + telemetrySession.LogOutput, + new + { + schema = ResultSchemas.TelemetryWatch, + started_at = telemetrySession.StartedAt, + timeout_sec = validatedTimeoutSec, + invocation = telemetrySession.Invocation + }, + telemetrySession.Parsed).ConfigureAwait(false); + } + + public Task WaitForStepAsync(string step, int timeoutSec) + { + var expectedStep = NormalizeTelemetryStep(RequireNonBlank(step, "waitStep requires step.")); + var validatedTimeoutSec = RequirePositive(timeoutSec, "waitStep requires timeoutSec greater than zero."); + return _telemetryMonitor.WaitForTelemetryEventAsync( + validatedTimeoutSec, + telemetry => string.Equals(telemetry.Event, "step", StringComparison.OrdinalIgnoreCase) + && string.Equals(NormalizeTelemetryStep(telemetry.Step), expectedStep, StringComparison.Ordinal), + telemetry => new TelemetryMatchResult(expectedStep, null, telemetry.RawLine, telemetry.Event!, telemetry.Payload), + DeviceArtifactNames.WaitStepBaseName, + invocation => new + { + schema = ResultSchemas.WaitStep, + step = expectedStep, + timeout_sec = validatedTimeoutSec, + invocation + }, + () => new SemanticWaitTimeoutException($"device step '{expectedStep}'", validatedTimeoutSec)); + } + + public Task WaitForActionReadyAsync(string action, string? step, int timeoutSec) + { + var expectedAction = RequireNonBlank(action, "waitActionReady requires action."); + var normalizedStep = NormalizeTelemetryStep(step); + var validatedTimeoutSec = RequirePositive(timeoutSec, "waitActionReady requires timeoutSec greater than zero."); + return _telemetryMonitor.WaitForTelemetryEventAsync( + validatedTimeoutSec, + telemetry => + string.Equals(telemetry.Event, "action_ready", StringComparison.OrdinalIgnoreCase) && + string.Equals(telemetry.Action, expectedAction, StringComparison.OrdinalIgnoreCase) && + (normalizedStep is null || string.Equals(NormalizeTelemetryStep(telemetry.Step), normalizedStep, StringComparison.Ordinal)), + telemetry => new TelemetryMatchResult(normalizedStep, expectedAction, telemetry.RawLine, telemetry.Event!, telemetry.Payload), + DeviceArtifactNames.WaitActionReadyBaseName, + invocation => new + { + schema = ResultSchemas.WaitActionReady, + action = expectedAction, + step = normalizedStep, + timeout_sec = validatedTimeoutSec, + invocation + }, + () => new SemanticWaitTimeoutException($"device action ready '{expectedAction}'" + (normalizedStep is null ? string.Empty : $" on '{normalizedStep}'"), validatedTimeoutSec)); + } + + private static string RequireNonBlank(string value, string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new UsageException(message); + } + + return value; + } + + private static int RequirePositive(int value, string message) + { + if (value <= 0) + { + throw new UsageException(message); + } + + return value; + } + + private static string? NormalizeTelemetryStep(string? step) + { + if (string.IsNullOrWhiteSpace(step)) + { + return null; + } + + var normalized = step.Trim().ToUpperInvariant().Replace('-', '_'); + return normalized.StartsWith("STEP_", StringComparison.Ordinal) ? normalized : $"STEP_{normalized}"; + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Hosts/Android/AndroidWirelessDebugOperations.cs b/Luotsi.Cli/Hosts/Android/AndroidWirelessDebugOperations.cs new file mode 100644 index 0000000..1e54622 --- /dev/null +++ b/Luotsi.Cli/Hosts/Android/AndroidWirelessDebugOperations.cs @@ -0,0 +1,140 @@ +using System.Globalization; +using Luotsi.Cli.Errors; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Models; + +namespace Luotsi.Cli.Hosts.Android; + +internal sealed class AndroidWirelessDebugOperations(IAdbClient adb) +{ + private readonly IAdbClient _adb = adb ?? throw new ArgumentNullException(nameof(adb)); + + public async Task EnableWirelessAsync(string? host, int port) + { + if (port <= 0 || port > 65535) + { + throw new UsageException("wireless requires --port between 1 and 65535."); + } + + var validatedHost = string.IsNullOrWhiteSpace(host) + ? await DetectWirelessHostAsync().ConfigureAwait(false) + : host.Trim(); + var tcpip = await _adb.RunAsync(["tcpip", port.ToString(CultureInfo.InvariantCulture)]).ConfigureAwait(false); + tcpip.EnsureSuccess("adb tcpip failed"); + var endpoint = $"{validatedHost}:{port}"; + var connect = await _adb.RunAsync(["connect", endpoint]).ConfigureAwait(false); + connect.EnsureSuccess("adb connect failed"); + return new WirelessConnectResult(validatedHost, port, endpoint); + } + + public async Task ScanWirelessServicesAsync() + { + var result = await _adb.RunAsync(["mdns", "services"]).ConfigureAwait(false); + result.EnsureSuccess("adb mdns services failed"); + return WirelessDebugResolver.CreateScanResult(WirelessDebugResolver.ParseMdnsServices(result.Stdout)); + } + + public async Task PairWirelessAsync(string? endpoint, string? service, string? pairingCode) + { + var target = await ResolvePairingServiceAsync(endpoint, service).ConfigureAwait(false); + var normalizedCode = string.IsNullOrWhiteSpace(pairingCode) ? null : pairingCode.Trim(); + if (normalizedCode is null) + { + return new WirelessPairResult( + target.Endpoint, + target.ServiceName, + target.ServiceType, + target.Selector, + Paired: false, + InteractiveRequired: true, + $"Luotsi cannot drive adb's interactive pairing prompt while preserving one JSON command envelope. Pass --code , or run `adb pair {target.Endpoint}` manually.", + Stdout: null); + } + + var result = await _adb.RunAsync(["pair", target.Endpoint, normalizedCode]).ConfigureAwait(false); + result.EnsureSuccess("adb pair failed"); + var stdout = result.Stdout.Trim(); + return new WirelessPairResult( + target.Endpoint, + target.ServiceName, + target.ServiceType, + target.Selector, + Paired: true, + InteractiveRequired: false, + string.IsNullOrWhiteSpace(stdout) ? $"Paired to {target.Endpoint}." : stdout, + string.IsNullOrWhiteSpace(stdout) ? null : stdout); + } + + public async Task ConnectWirelessAsync(string? endpoint, string? service) + { + var target = await ResolveConnectServiceAsync(endpoint, service).ConfigureAwait(false); + var connectTarget = target.Endpoint; + var result = await _adb.RunAsync(["connect", connectTarget]).ConfigureAwait(false); + result.EnsureSuccess("adb connect failed"); + var stdout = result.Stdout.Trim(); + return new WirelessMdnsConnectResult( + target.Endpoint, + target.ServiceName, + target.ServiceType, + target.Selector, + connectTarget, + target.Selector ?? target.Endpoint, + Connected: true, + string.IsNullOrWhiteSpace(stdout) ? $"Connected to {connectTarget}." : stdout, + string.IsNullOrWhiteSpace(stdout) ? null : stdout); + } + + private async Task ResolvePairingServiceAsync(string? endpoint, string? service) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return WirelessDebugResolver.ResolvePairingService([], endpoint, service); + } + + var scan = await ScanWirelessServicesAsync().ConfigureAwait(false); + return WirelessDebugResolver.ResolvePairingService(scan.PairingServices, endpoint, service); + } + + private async Task ResolveConnectServiceAsync(string? endpoint, string? service) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return WirelessDebugResolver.ResolveConnectService(new WirelessScanResult([], [], [], []), endpoint, service); + } + + var scan = await ScanWirelessServicesAsync().ConfigureAwait(false); + return WirelessDebugResolver.ResolveConnectService(scan, endpoint, service); + } + + private async Task DetectWirelessHostAsync() + { + var route = await _adb.ShellAsync("ip route get 8.8.8.8").ConfigureAwait(false); + route.EnsureSuccess("wireless host auto-detection failed"); + var sourceAddress = ParseRouteSourceAddress(route.Stdout); + if (string.IsNullOrWhiteSpace(sourceAddress)) + { + throw new UsageException("wireless could not auto-detect the device Wi-Fi IP address. Pass --host ."); + } + + return sourceAddress; + } + + internal static string? ParseRouteSourceAddress(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + var tokens = output.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + for (var index = 0; index < tokens.Length - 1; index++) + { + if (string.Equals(tokens[index], "src", StringComparison.OrdinalIgnoreCase)) + { + return tokens[index + 1]; + } + } + + return null; + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Hosts/Android/DeviceRunner.cs b/Luotsi.Cli/Hosts/Android/DeviceRunner.cs index 88c6237..d270ea7 100644 --- a/Luotsi.Cli/Hosts/Android/DeviceRunner.cs +++ b/Luotsi.Cli/Hosts/Android/DeviceRunner.cs @@ -149,43 +149,43 @@ public async Task ScrollAsync(int horizontalTicks, int verticalTic => await UiInteractions.ScrollAsync(horizontalTicks, verticalTicks).ConfigureAwait(false); public async Task PushFileAsync(string localPath, string? remoteDirectory = null) - => await DeviceControl.PushFileAsync(localPath, remoteDirectory).ConfigureAwait(false); + => await FileAndPortControl.PushFileAsync(localPath, remoteDirectory).ConfigureAwait(false); public async Task PullFileAsync(string remotePath, string? localDirectory = null) - => await DeviceControl.PullFileAsync(remotePath, localDirectory).ConfigureAwait(false); + => await FileAndPortControl.PullFileAsync(remotePath, localDirectory).ConfigureAwait(false); public async Task ListForwardsAsync() - => await DeviceControl.ListForwardsAsync().ConfigureAwait(false); + => await FileAndPortControl.ListForwardsAsync().ConfigureAwait(false); public async Task ForwardAsync(string local, string remote, bool noRebind) - => await DeviceControl.ForwardAsync(local, remote, noRebind).ConfigureAwait(false); + => await FileAndPortControl.ForwardAsync(local, remote, noRebind).ConfigureAwait(false); public async Task RemoveForwardAsync(string local) - => await DeviceControl.RemoveForwardAsync(local).ConfigureAwait(false); + => await FileAndPortControl.RemoveForwardAsync(local).ConfigureAwait(false); public async Task ListReversesAsync() - => await DeviceControl.ListReversesAsync().ConfigureAwait(false); + => await FileAndPortControl.ListReversesAsync().ConfigureAwait(false); public async Task ReverseAsync(string remote, string local, bool noRebind) - => await DeviceControl.ReverseAsync(remote, local, noRebind).ConfigureAwait(false); + => await FileAndPortControl.ReverseAsync(remote, local, noRebind).ConfigureAwait(false); public async Task RemoveReverseAsync(string remote) - => await DeviceControl.RemoveReverseAsync(remote).ConfigureAwait(false); + => await FileAndPortControl.RemoveReverseAsync(remote).ConfigureAwait(false); public async Task EnableWirelessAsync(string? host, int port) - => await DeviceControl.EnableWirelessAsync(host, port).ConfigureAwait(false); + => await WirelessDebug.EnableWirelessAsync(host, port).ConfigureAwait(false); public async Task ScanWirelessServicesAsync() - => await DeviceControl.ScanWirelessServicesAsync().ConfigureAwait(false); + => await WirelessDebug.ScanWirelessServicesAsync().ConfigureAwait(false); public async Task PairWirelessAsync(string? endpoint, string? service, string? pairingCode) - => await DeviceControl.PairWirelessAsync(endpoint, service, pairingCode).ConfigureAwait(false); + => await WirelessDebug.PairWirelessAsync(endpoint, service, pairingCode).ConfigureAwait(false); public async Task ConnectWirelessAsync(string? endpoint, string? service) - => await DeviceControl.ConnectWirelessAsync(endpoint, service).ConfigureAwait(false); + => await WirelessDebug.ConnectWirelessAsync(endpoint, service).ConfigureAwait(false); internal static IReadOnlyList ParseWirelessMdnsServices(string output) => - AndroidDeviceControlOperations.ParseWirelessMdnsServices(output); + WirelessDebugResolver.ParseMdnsServices(output); public async Task InstallPackageAsync(string packagePath) => await DeviceControl.InstallPackageAsync(packagePath).ConfigureAwait(false); @@ -221,7 +221,7 @@ public async Task RevokePermissionAsync(string packageN => await DeviceControl.RevokePermissionAsync(packageName, permission).ConfigureAwait(false); public async Task WaitForLogAsync(string text, int timeoutSec) - => await LogAndTelemetry.WaitForLogAsync(text, timeoutSec).ConfigureAwait(false); + => await LogMonitor.WaitForLogAsync(text, timeoutSec).ConfigureAwait(false); /// /// Reads logcat. @@ -229,7 +229,7 @@ public async Task WaitForLogAsync(string text, int timeoutSec) /// Maximum lines to return. /// Logcat lines. public async Task LogcatAsync(int tail) - => await LogAndTelemetry.LogcatAsync(tail).ConfigureAwait(false); + => await LogMonitor.LogcatAsync(tail).ConfigureAwait(false); /// /// Reads and parses recent semantic telemetry events. @@ -237,7 +237,7 @@ public async Task LogcatAsync(int tail) /// Maximum logcat lines to inspect. /// Telemetry data. public async Task TelemetryTailAsync(int tail) - => await LogAndTelemetry.TelemetryTailAsync(tail).ConfigureAwait(false); + => await SemanticTelemetry.TelemetryTailAsync(tail).ConfigureAwait(false); /// /// Collects semantic telemetry events over a bounded watch window. @@ -245,7 +245,7 @@ public async Task TelemetryTailAsync(int tail) /// Duration to watch for telemetry events. /// Telemetry data. public async Task TelemetryWatchAsync(int timeoutSec) - => await LogAndTelemetry.TelemetryWatchAsync(timeoutSec).ConfigureAwait(false); + => await SemanticTelemetry.TelemetryWatchAsync(timeoutSec).ConfigureAwait(false); /// /// Waits for a semantic telemetry step event. @@ -254,7 +254,7 @@ public async Task TelemetryWatchAsync(int timeoutSec) /// Timeout in seconds. /// Matched telemetry data. public Task WaitForStepAsync(string step, int timeoutSec) => - LogAndTelemetry.WaitForStepAsync(step, timeoutSec); + SemanticTelemetry.WaitForStepAsync(step, timeoutSec); /// /// Waits for a semantic telemetry action-ready event. @@ -264,7 +264,7 @@ public Task WaitForStepAsync(string step, int timeoutSec) /// Timeout in seconds. /// Matched telemetry data. public Task WaitForActionReadyAsync(string action, string? step, int timeoutSec) => - LogAndTelemetry.WaitForActionReadyAsync(action, step, timeoutSec); + SemanticTelemetry.WaitForActionReadyAsync(action, step, timeoutSec); /// /// Records video with Android screenrecord. @@ -294,10 +294,10 @@ public async Task TypePinAsync(string pin, int perDigitDelayMs) => await UiInteractions.TypePinAsync(pin, perDigitDelayMs).ConfigureAwait(false); public async Task ResetLogAsync() - => await LogAndTelemetry.ResetLogAsync().ConfigureAwait(false); + => await LogMonitor.ResetLogAsync().ConfigureAwait(false); public async Task AssertEventAsync(string name, IReadOnlyList contains, string? detailsPattern, int timeoutSec, DateTimeOffset? since = null) - => await LogAndTelemetry.AssertEventAsync(name, contains, detailsPattern, timeoutSec, since).ConfigureAwait(false); + => await LogMonitor.AssertEventAsync(name, contains, detailsPattern, timeoutSec, since).ConfigureAwait(false); public async Task TakeScreenshotAsync(string label) => await ArtifactOperations.TakeScreenshotAsync(label).ConfigureAwait(false); @@ -322,6 +322,11 @@ private void InvalidateUiReadCaches() ScreenStateReadModel.InvalidateUiReadCaches(); } + private AndroidFileAndPortOperations FileAndPortControl => + field ??= new AndroidFileAndPortOperations( + _adb, + _fileSystem); + private AndroidDeviceControlOperations DeviceControl => field ??= new AndroidDeviceControlOperations( _adb, @@ -330,6 +335,9 @@ private void InvalidateUiReadCaches() _fileSystem, InvalidateUiReadCaches); + private AndroidWirelessDebugOperations WirelessDebug => + field ??= new AndroidWirelessDebugOperations(_adb); + private AndroidDeviceReadinessOperations DeviceReadiness => field ??= new AndroidDeviceReadinessOperations( _adb, @@ -351,11 +359,15 @@ private void InvalidateUiReadCaches() _delay, _environment); - private AndroidLogAndTelemetryOperations LogAndTelemetry => - field ??= new AndroidLogAndTelemetryOperations( + private AndroidLogMonitorOperations LogMonitor => + field ??= new AndroidLogMonitorOperations( _adb, _artifacts, - _timeProvider, + _timeProvider); + + private AndroidSemanticTelemetryOperations SemanticTelemetry => + field ??= new AndroidSemanticTelemetryOperations( + _adb, _telemetryMonitor); private AndroidArtifactOperations ArtifactOperations => diff --git a/Luotsi.Cli/Hosts/Android/View/AndroidMediaProjectionConsentApprover.cs b/Luotsi.Cli/Hosts/Android/View/AndroidMediaProjectionConsentApprover.cs new file mode 100644 index 0000000..92d39f0 --- /dev/null +++ b/Luotsi.Cli/Hosts/Android/View/AndroidMediaProjectionConsentApprover.cs @@ -0,0 +1,96 @@ +using Luotsi.Cli.Infrastructure.Contracts; + +namespace Luotsi.Cli.Hosts.Android.View; + +internal sealed class AndroidMediaProjectionConsentApprover(IAdbClient adbClient) +{ + private const int MaxAttempts = 8; + private const string UiDumpRemotePath = "/data/local/tmp/luotsi-view-window.xml"; + + private readonly IAdbClient _adbClient = adbClient ?? throw new ArgumentNullException(nameof(adbClient)); + + public async Task TryApproveAsync(CancellationToken cancellationToken = default) + { + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var uiXml = await DumpUiHierarchyAsync(cancellationToken).ConfigureAwait(false); + if (uiXml is null) + { + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false); + continue; + } + + if (TryFindStartNowButtonCenter(uiXml, out var x, out var y)) + { + var tap = await _adbClient.ShellAsync($"input tap {x} {y}", cancellationToken).ConfigureAwait(false); + tap.EnsureSuccess("view helper MediaProjection consent tap failed"); + return true; + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false); + } + + return false; + } + + private async Task DumpUiHierarchyAsync(CancellationToken cancellationToken) + { + using var dumpCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + dumpCancellation.CancelAfter(TimeSpan.FromSeconds(8)); + try + { + var dump = await _adbClient.ShellAsync($"uiautomator dump {UiDumpRemotePath} >/dev/null && cat {UiDumpRemotePath} && rm -f {UiDumpRemotePath}", dumpCancellation.Token).ConfigureAwait(false); + return dump.Stdout; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + } + + private static bool TryFindStartNowButtonCenter(string uiXml, out int x, out int y) + { + const string marker = "START NOW"; + var textIndex = uiXml.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (textIndex < 0) + { + x = 0; + y = 0; + return false; + } + + var boundsIndex = uiXml.IndexOf("bounds=\"[", textIndex, StringComparison.OrdinalIgnoreCase); + if (boundsIndex < 0) + { + x = 0; + y = 0; + return false; + } + + var start = boundsIndex + "bounds=\"[".Length; + var end = uiXml.IndexOf("]\"", start, StringComparison.Ordinal); + if (end <= start) + { + x = 0; + y = 0; + return false; + } + + var parts = uiXml[start..end].Split([',', ']', '['], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 4 || + !int.TryParse(parts[0], out var left) || + !int.TryParse(parts[1], out var top) || + !int.TryParse(parts[2], out var right) || + !int.TryParse(parts[3], out var bottom)) + { + x = 0; + y = 0; + return false; + } + + x = (left + right) / 2; + y = (top + bottom) / 2; + return true; + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Hosts/Android/View/AndroidViewBootstrap.cs b/Luotsi.Cli/Hosts/Android/View/AndroidViewBootstrap.cs index e77c648..6f1e8ac 100644 --- a/Luotsi.Cli/Hosts/Android/View/AndroidViewBootstrap.cs +++ b/Luotsi.Cli/Hosts/Android/View/AndroidViewBootstrap.cs @@ -1,139 +1,10 @@ using Luotsi.Cli.Errors; using Luotsi.Cli.Infrastructure.Contracts; -using Luotsi.Cli.View; using Luotsi.Cli.View.Contracts; using Luotsi.Cli.View.Transport; namespace Luotsi.Cli.Hosts.Android.View; -/// -/// Locates the packaged Android view helper. -/// -public interface IAndroidViewHelperPackageLocator -{ - /// - /// Resolves the helper package to install on the device. - /// - /// Resolved helper package. - AndroidViewHelperPackage Resolve(); -} - -/// -/// Android helper package metadata. -/// -/// Host-local package path. -/// Remote installation path. -/// App process entry point. -/// Helper version string. -/// Installed Android package name. -/// Component name for the MediaProjection consent activity. -/// Component name for the MediaProjection capture service. -public sealed record AndroidViewHelperPackage( - string LocalPath, - string RemotePath, - string MainClass, - string Version, - string PackageName = AndroidRuntimeDefaults.ViewHelperPackageName, - string ConsentActivity = AndroidRuntimeDefaults.ViewHelperConsentActivity, - string CaptureService = AndroidRuntimeDefaults.ViewHelperCaptureService); - -/// -/// Default helper package locator. -/// -public sealed class AndroidViewHelperPackageLocator(IEnvironmentVariables environment, IFileSystem fileSystem) : IAndroidViewHelperPackageLocator -{ - private readonly IEnvironmentVariables _environment = environment ?? throw new ArgumentNullException(nameof(environment)); - private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); - private readonly ViewHostPathResolver _pathResolver = new(environment); - - /// - public AndroidViewHelperPackage Resolve() - { - var localPath = _environment.GetEnvironmentVariable(AndroidRuntimeDefaults.ViewHelperPathEnvironmentVariable); - if (string.IsNullOrWhiteSpace(localPath)) - { - foreach (var candidate in _pathResolver.GetRepositoryRelativeFileCandidates(AndroidRuntimeDefaults.DefaultViewHelperRelativePath)) - { - if (_fileSystem.FileExists(candidate)) - { - localPath = candidate; - break; - } - } - } - - if (string.IsNullOrWhiteSpace(localPath) || !_fileSystem.FileExists(localPath)) - { - throw new InvalidOperationException($"Android view helper package was not found. Set {AndroidRuntimeDefaults.ViewHelperPathEnvironmentVariable} or build the helper APK at {AndroidRuntimeDefaults.DefaultViewHelperRelativePath}"); - } - - return new AndroidViewHelperPackage(localPath, AndroidRuntimeDefaults.ViewHelperRemotePath, AndroidRuntimeDefaults.ViewHelperMainClass, AndroidRuntimeDefaults.ViewHelperVersion); - } -} - -/// -/// Installs the Android view helper on the device. -/// -public sealed class AndroidViewServerInstaller(IAdbClient adbClient, IAndroidViewHelperPackageLocator packageLocator) -{ - private readonly IAdbClient _adbClient = adbClient ?? throw new ArgumentNullException(nameof(adbClient)); - private readonly IAndroidViewHelperPackageLocator _packageLocator = packageLocator ?? throw new ArgumentNullException(nameof(packageLocator)); - - /// - /// Resolves and installs the helper package. - /// - /// Cancellation token. - /// Installed helper metadata. - public async Task InstallAsync(CancellationToken cancellationToken = default) - { - var package = _packageLocator.Resolve(); - return await InstallAsync(package, cancellationToken).ConfigureAwait(false); - } - - /// - /// Installs a previously resolved helper package. - /// - /// Resolved helper package. - /// Cancellation token. - /// Installed helper metadata. - public async Task InstallAsync(AndroidViewHelperPackage package, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(package); - - var result = await _adbClient.RunAsync(["install", "-r", package.LocalPath], cancellationToken).ConfigureAwait(false); - result.EnsureSuccess("view helper install failed"); - return package; - } - - /// - /// Pushes the helper APK for the legacy app_process screenrecord entry point. - /// - public async Task PushForAppProcessAsync(AndroidViewHelperPackage package, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(package); - - var result = await _adbClient.RunAsync(["push", package.LocalPath, package.RemotePath], cancellationToken).ConfigureAwait(false); - result.EnsureSuccess("view helper push failed"); - return package; - } - - /// - /// Removes the helper package from the device. - /// - /// Remote path to remove. - /// Cancellation token. - /// Completion task. - public async Task RemoveAsync(string remotePath, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(remotePath)) - { - return; - } - - await _adbClient.ShellAsync($"rm -f {remotePath}", cancellationToken).ConfigureAwait(false); - } -} - /// /// Android adb-forward transport bootstrap for the built-in mirror. /// @@ -156,7 +27,7 @@ public sealed class AndroidViewBootstrap( private IAsyncDisposable? _screenrecordShell; /// - public async Task StartAsync(ViewStartRequest request, CancellationToken cancellationToken = default) + public async Task StartAsync(ViewStartRequest request, Action? reportPhase = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -172,14 +43,16 @@ public async Task StartAsync(ViewStartRequest request, Cance var sessionId = _idGenerator.NewId(); var socketName = $"{AndroidRuntimeDefaults.ViewSocketPrefix}{sessionId}"; - var adbClient = _adbClientFactory.Create(request.AdbExecutable, request.DeviceSelector, _processRunner); - var installer = new AndroidViewServerInstaller(adbClient, _packageLocator); + var adbClient = _adbClientFactory.Create(request.AdbExecutable, request.DeviceSelector, _processRunner, request.CommandTimeout); + var installer = new AndroidViewServerInstaller(adbClient, _packageLocator, reportPhase); _adbClient = adbClient; _socketName = socketName; try { + Report(reportPhase, "helper_resolve", ViewStartupPhaseStatus.Started, "Resolving Android view helper package."); var package = _packageLocator.Resolve(); + Report(reportPhase, "helper_resolve", ViewStartupPhaseStatus.Succeeded, "Resolved Android view helper package.", AndroidViewServerInstaller.PackageDetail(package)); _installedPackage = package; if (string.Equals(activeBackend, ViewCaptureBackends.MediaProjection, StringComparison.Ordinal)) @@ -192,13 +65,17 @@ public async Task StartAsync(ViewStartRequest request, Cance await installer.PushForAppProcessAsync(package, cancellationToken).ConfigureAwait(false); } + Report(reportPhase, "adb_forward", ViewStartupPhaseStatus.Started, "Creating adb forward for view stream.", $"local=tcp:0; remote=localabstract:{socketName}"); var forward = await adbClient.RunAsync(["forward", "tcp:0", $"localabstract:{socketName}"], cancellationToken).ConfigureAwait(false); forward.EnsureSuccess("view transport forward failed"); var localPort = await ResolveForwardedLocalPortAsync(adbClient, forward, socketName, cancellationToken).ConfigureAwait(false); + Report(reportPhase, "adb_forward", ViewStartupPhaseStatus.Succeeded, "ADB forward is ready.", $"local=tcp:{localPort}; remote=localabstract:{socketName}"); _localPort = localPort; if (string.Equals(activeBackend, ViewCaptureBackends.MediaProjection, StringComparison.Ordinal)) { + var consentApprover = new AndroidMediaProjectionConsentApprover(adbClient); + Report(reportPhase, "mediaprojection_activity", ViewStartupPhaseStatus.Started, "Starting Android MediaProjection consent activity.", package.ConsentActivity); var start = await adbClient.RunAsync([ "shell", "am", @@ -222,13 +99,25 @@ public async Task StartAsync(ViewStartRequest request, Cance request.VideoBitRate ], cancellationToken).ConfigureAwait(false); start.EnsureSuccess("view helper activity start failed"); - await TryApproveMediaProjectionConsentAsync(adbClient, cancellationToken).ConfigureAwait(false); + Report(reportPhase, "mediaprojection_activity", ViewStartupPhaseStatus.Succeeded, "Android MediaProjection consent activity started.", start.Stdout.Trim()); + Report(reportPhase, "mediaprojection_consent", ViewStartupPhaseStatus.Started, "Waiting for Android MediaProjection consent prompt.", "uiautomator=start-now"); + var approved = await consentApprover.TryApproveAsync(cancellationToken).ConfigureAwait(false); + if (!approved) + { + var message = "MediaProjection consent prompt was not approved or could not be detected."; + Report(reportPhase, "mediaprojection_consent", ViewStartupPhaseStatus.Failed, message, null, "Approve the Android screen-capture prompt on the device, or use --capture-backend auto/screenrecord."); + throw new MediaProjectionConsentException(message); + } + + Report(reportPhase, "mediaprojection_consent", ViewStartupPhaseStatus.Succeeded, "Android MediaProjection consent was approved."); } else { var shellCommand = string.Join( " ", $"CLASSPATH={ShellQuote(package.RemotePath)}", "app_process", "/", ShellQuote(package.MainClass), "--socket", ShellQuote(socketName), "--codec", ShellQuote(request.Codec), "--max-size", request.MaxSize.ToString(System.Globalization.CultureInfo.InvariantCulture), "--max-fps", request.MaxFps.ToString(System.Globalization.CultureInfo.InvariantCulture), "--video-bit-rate", ShellQuote(request.VideoBitRate)); + Report(reportPhase, "screenrecord_process", ViewStartupPhaseStatus.Started, "Starting screenrecord helper process.", package.MainClass); _screenrecordShell = await adbClient.StartShellAsync(shellCommand, cancellationToken).ConfigureAwait(false); + Report(reportPhase, "screenrecord_process", ViewStartupPhaseStatus.Succeeded, "Screenrecord helper process started."); } return new ViewConnectionInfo( @@ -249,6 +138,9 @@ public async Task StartAsync(ViewStartRequest request, Cance } } + private static void Report(Action? reportPhase, string phase, string status, string summary, string? detail = null, string? recommendation = null) => + reportPhase?.Invoke(new ViewStartupPhase(phase, status, summary, string.IsNullOrWhiteSpace(detail) ? null : detail, recommendation)); + private static string NormalizeCaptureBackend(string? captureBackend) { if (string.IsNullOrWhiteSpace(captureBackend)) @@ -267,76 +159,6 @@ private static string NormalizeCaptureBackend(string? captureBackend) private static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; - private static async Task TryApproveMediaProjectionConsentAsync(IAdbClient adbClient, CancellationToken cancellationToken) - { - const int maxAttempts = 20; - for (var attempt = 0; attempt < maxAttempts; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - var uiXml = await DumpUiHierarchyAsync(adbClient, cancellationToken).ConfigureAwait(false); - if (TryFindStartNowButtonCenter(uiXml, out var x, out var y)) - { - var tap = await adbClient.ShellAsync($"input tap {x} {y}", cancellationToken).ConfigureAwait(false); - tap.EnsureSuccess("view helper MediaProjection consent tap failed"); - return; - } - - await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false); - } - } - - private static async Task DumpUiHierarchyAsync(IAdbClient adbClient, CancellationToken cancellationToken) - { - const string remotePath = "/data/local/tmp/luotsi-view-window.xml"; - var dump = await adbClient.ShellAsync($"uiautomator dump {remotePath} >/dev/null && cat {remotePath} && rm -f {remotePath}", cancellationToken).ConfigureAwait(false); - return dump.Stdout; - } - - private static bool TryFindStartNowButtonCenter(string uiXml, out int x, out int y) - { - const string marker = "START NOW"; - var textIndex = uiXml.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (textIndex < 0) - { - x = 0; - y = 0; - return false; - } - - var boundsIndex = uiXml.IndexOf("bounds=\"[", textIndex, StringComparison.OrdinalIgnoreCase); - if (boundsIndex < 0) - { - x = 0; - y = 0; - return false; - } - - var start = boundsIndex + "bounds=\"[".Length; - var end = uiXml.IndexOf("]\"", start, StringComparison.Ordinal); - if (end <= start) - { - x = 0; - y = 0; - return false; - } - - var parts = uiXml[start..end].Split([',', ']', '['], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length != 4 || - !int.TryParse(parts[0], out var left) || - !int.TryParse(parts[1], out var top) || - !int.TryParse(parts[2], out var right) || - !int.TryParse(parts[3], out var bottom)) - { - x = 0; - y = 0; - return false; - } - - x = (left + right) / 2; - y = (top + bottom) / 2; - return true; - } - private static async Task ResolveForwardedLocalPortAsync( IAdbClient adbClient, AdbCommandResult forward, diff --git a/Luotsi.Cli/Hosts/Android/View/AndroidViewHelperPackageLocator.cs b/Luotsi.Cli/Hosts/Android/View/AndroidViewHelperPackageLocator.cs new file mode 100644 index 0000000..0a22307 --- /dev/null +++ b/Luotsi.Cli/Hosts/Android/View/AndroidViewHelperPackageLocator.cs @@ -0,0 +1,103 @@ +using System.Linq; +using System.Security.Cryptography; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.View; + +namespace Luotsi.Cli.Hosts.Android.View; + +/// +/// Locates the packaged Android view helper. +/// +public interface IAndroidViewHelperPackageLocator +{ + /// + /// Resolves the helper package to install on the device. + /// + /// Resolved helper package. + AndroidViewHelperPackage Resolve(); +} + +/// +/// Android helper package metadata. +/// +/// Host-local package path. +/// Remote installation path. +/// App process entry point. +/// Helper version string. +/// Installed Android package name. +/// Component name for the MediaProjection consent activity. +/// Component name for the MediaProjection capture service. +/// Host-local package size in bytes. +/// Host-local package SHA-256. +/// How the package path was resolved. +public sealed record AndroidViewHelperPackage( + string LocalPath, + string RemotePath, + string MainClass, + string Version, + string PackageName = AndroidRuntimeDefaults.ViewHelperPackageName, + string ConsentActivity = AndroidRuntimeDefaults.ViewHelperConsentActivity, + string CaptureService = AndroidRuntimeDefaults.ViewHelperCaptureService, + long? LocalSizeBytes = null, + string? LocalSha256 = null, + string ResolutionSource = "explicit"); + +/// +/// Default helper package locator. +/// +public sealed class AndroidViewHelperPackageLocator(IEnvironmentVariables environment, IFileSystem fileSystem) : IAndroidViewHelperPackageLocator +{ + private readonly IEnvironmentVariables _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + private readonly ViewHostPathResolver _pathResolver = new(environment); + + /// + public AndroidViewHelperPackage Resolve() + { + var localPath = _environment.GetEnvironmentVariable(AndroidRuntimeDefaults.ViewHelperPathEnvironmentVariable); + var resolutionSource = AndroidRuntimeDefaults.ViewHelperPathEnvironmentVariable; + if (string.IsNullOrWhiteSpace(localPath)) + { + resolutionSource = "repository_default"; + localPath = _pathResolver + .GetRepositoryRelativeFileCandidates(AndroidRuntimeDefaults.DefaultViewHelperRelativePath) + .Where(_fileSystem.FileExists) + .FirstOrDefault(); + } + + if (string.IsNullOrWhiteSpace(localPath) || !_fileSystem.FileExists(localPath)) + { + throw new InvalidOperationException($"Android view helper package was not found. Set {AndroidRuntimeDefaults.ViewHelperPathEnvironmentVariable} or build the helper APK at {AndroidRuntimeDefaults.DefaultViewHelperRelativePath}"); + } + + var normalizedPath = Path.GetFullPath(localPath); + var packagePath = _fileSystem.FileExists(normalizedPath) ? normalizedPath : localPath; + if (!string.Equals(Path.GetExtension(packagePath), ".apk", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Android view helper package must be an .apk file: {packagePath}"); + } + + var (sizeBytes, sha256) = ReadPackageFingerprint(packagePath); + if (sizeBytes <= 0) + { + throw new InvalidOperationException($"Android view helper package is empty: {packagePath}"); + } + + return new AndroidViewHelperPackage( + packagePath, + AndroidRuntimeDefaults.ViewHelperRemotePath, + AndroidRuntimeDefaults.ViewHelperMainClass, + AndroidRuntimeDefaults.ViewHelperVersion, + LocalSizeBytes: sizeBytes, + LocalSha256: sha256, + ResolutionSource: resolutionSource); + } + + private (long SizeBytes, string Sha256) ReadPackageFingerprint(string path) + { + using var stream = _fileSystem.OpenRead(path); + var sizeBytes = stream.CanSeek ? stream.Length : 0; + var hash = SHA256.HashData(stream); + return (sizeBytes, Convert.ToHexString(hash).ToLowerInvariant()); + } +} diff --git a/Luotsi.Cli/Hosts/Android/View/AndroidViewServerInstaller.cs b/Luotsi.Cli/Hosts/Android/View/AndroidViewServerInstaller.cs new file mode 100644 index 0000000..80d493f --- /dev/null +++ b/Luotsi.Cli/Hosts/Android/View/AndroidViewServerInstaller.cs @@ -0,0 +1,144 @@ +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.View.Contracts; + +namespace Luotsi.Cli.Hosts.Android.View; + +/// +/// Installs the Android view helper on the device. +/// +public sealed class AndroidViewServerInstaller( + IAdbClient adbClient, + IAndroidViewHelperPackageLocator packageLocator, + Action? reportPhase = null) +{ + private readonly IAdbClient _adbClient = adbClient ?? throw new ArgumentNullException(nameof(adbClient)); + private readonly IAndroidViewHelperPackageLocator _packageLocator = packageLocator ?? throw new ArgumentNullException(nameof(packageLocator)); + private readonly Action? _reportPhase = reportPhase; + + /// + /// Resolves and installs the helper package. + /// + /// Cancellation token. + /// Installed helper metadata. + public async Task InstallAsync(CancellationToken cancellationToken = default) + { + var package = _packageLocator.Resolve(); + return await InstallAsync(package, cancellationToken).ConfigureAwait(false); + } + + /// + /// Installs a previously resolved helper package. + /// + /// Resolved helper package. + /// Cancellation token. + /// Installed helper metadata. + public async Task InstallAsync(AndroidViewHelperPackage package, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(package); + + Report("helper_install", ViewStartupPhaseStatus.Started, "Installing Android view helper.", PackageDetail(package)); + try + { + var result = await _adbClient.RunAsync(["install", "-r", package.LocalPath], cancellationToken).ConfigureAwait(false); + result.EnsureSuccess("view helper install failed"); + Report("helper_install", ViewStartupPhaseStatus.Succeeded, "Android view helper installed.", result.Stdout.Trim()); + } + catch (Exception ex) + { + Report("helper_install", ViewStartupPhaseStatus.Failed, "Android view helper install failed.", ex.Message, "Run view setup --fix or rebuild the Android helper APK."); + throw; + } + + await VerifyInstalledAsync(package, cancellationToken).ConfigureAwait(false); + return package; + } + + /// + /// Verifies that the installed helper exposes the expected Android components. + /// + public async Task VerifyInstalledAsync(AndroidViewHelperPackage package, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(package); + + Report("helper_verify", ViewStartupPhaseStatus.Started, "Verifying installed Android view helper components.", package.PackageName); + try + { + var activity = await _adbClient.RunAsync(["shell", "cmd", "package", "resolve-activity", "--brief", package.ConsentActivity], cancellationToken).ConfigureAwait(false); + activity.EnsureSuccess("view helper consent activity verification failed"); + if (!ContainsComponent(activity.Stdout, package.ConsentActivity)) + { + throw new InvalidOperationException($"Installed helper does not expose {package.ConsentActivity}. The APK manifest may be stale or incomplete."); + } + + var dump = await _adbClient.RunAsync(["shell", "pm", "dump", package.PackageName], cancellationToken).ConfigureAwait(false); + dump.EnsureSuccess("view helper package verification failed"); + var serviceClassName = ToClassName(package.CaptureService); + if (!dump.Stdout.Contains(serviceClassName, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Installed helper does not expose {serviceClassName}. The APK manifest may be stale or incomplete."); + } + + Report("helper_verify", ViewStartupPhaseStatus.Succeeded, "Installed Android view helper exposes required activity and service.", $"{package.ConsentActivity}; {serviceClassName}"); + } + catch (Exception ex) + { + Report("helper_verify", ViewStartupPhaseStatus.Failed, "Installed Android view helper verification failed.", ex.Message, "Rebuild Luotsi.ViewServer.Android and reinstall with view setup --fix."); + throw; + } + } + + /// + /// Pushes the helper APK for the legacy app_process screenrecord entry point. + /// + public async Task PushForAppProcessAsync(AndroidViewHelperPackage package, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(package); + + Report("helper_push", ViewStartupPhaseStatus.Started, "Pushing Android view helper for screenrecord backend.", PackageDetail(package)); + var result = await _adbClient.RunAsync(["push", package.LocalPath, package.RemotePath], cancellationToken).ConfigureAwait(false); + result.EnsureSuccess("view helper push failed"); + Report("helper_push", ViewStartupPhaseStatus.Succeeded, "Android view helper pushed.", result.Stdout.Trim()); + return package; + } + + /// + /// Removes the helper package from the device. + /// + /// Remote path to remove. + /// Cancellation token. + /// Completion task. + public async Task RemoveAsync(string remotePath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(remotePath)) + { + return; + } + + await _adbClient.ShellAsync($"rm -f {remotePath}", cancellationToken).ConfigureAwait(false); + } + + private void Report(string phase, string status, string summary, string? detail = null, string? recommendation = null) => + _reportPhase?.Invoke(new ViewStartupPhase(phase, status, summary, string.IsNullOrWhiteSpace(detail) ? null : detail, recommendation)); + + internal static string PackageDetail(AndroidViewHelperPackage package) => + $"path={package.LocalPath}; source={package.ResolutionSource}; size={package.LocalSizeBytes?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"}; sha256={package.LocalSha256 ?? "unknown"}"; + + private static bool ContainsComponent(string output, string component) => + output.Contains(component, StringComparison.Ordinal) || + output.Contains(ToClassName(component), StringComparison.Ordinal); + + private static string ToClassName(string component) + { + var slashIndex = component.IndexOf('/', StringComparison.Ordinal); + if (slashIndex < 0) + { + return component; + } + + var packageName = component[..slashIndex]; + var className = component[(slashIndex + 1)..]; + return className.StartsWith(".", StringComparison.Ordinal) + ? packageName + className + : className; + } +} diff --git a/Luotsi.Cli/Hosts/Android/View/MediaProjectionConsentException.cs b/Luotsi.Cli/Hosts/Android/View/MediaProjectionConsentException.cs new file mode 100644 index 0000000..9a71680 --- /dev/null +++ b/Luotsi.Cli/Hosts/Android/View/MediaProjectionConsentException.cs @@ -0,0 +1,3 @@ +namespace Luotsi.Cli.Hosts.Android.View; + +internal sealed class MediaProjectionConsentException(string message) : Exception(message); \ No newline at end of file diff --git a/Luotsi.Cli/Models/SessionEventTypes.cs b/Luotsi.Cli/Models/SessionEventTypes.cs index 6923edf..4c68e42 100644 --- a/Luotsi.Cli/Models/SessionEventTypes.cs +++ b/Luotsi.Cli/Models/SessionEventTypes.cs @@ -25,6 +25,7 @@ public static class Inspect public static class View { public const string Started = "view_started"; + public const string StartupPhase = "view_startup_phase"; public const string Stats = "view_stats"; public const string ShareStarted = "view_share_started"; public const string ShareClientConnected = "view_share_client_connected"; diff --git a/Luotsi.Cli/Scenarios/ScenarioCatalog.cs b/Luotsi.Cli/Scenarios/ScenarioCatalog.cs index 7617a4f..8c5602c 100644 --- a/Luotsi.Cli/Scenarios/ScenarioCatalog.cs +++ b/Luotsi.Cli/Scenarios/ScenarioCatalog.cs @@ -143,6 +143,15 @@ public async Task> DiscoverAsync(string path .ToArray(); } + public async Task LoadValidatedAsync(string file, IReadOnlySet supportedScenarioActions) + { + ArgumentException.ThrowIfNullOrWhiteSpace(file); + ArgumentNullException.ThrowIfNull(supportedScenarioActions); + + var scenario = _templateResolver.ResolveScenario(await LoadAsync(file).ConfigureAwait(false)); + return ScenarioValidator.ValidateScenario(scenario, file, supportedScenarioActions); + } + public static IReadOnlyList Filter(IReadOnlyList entries, ScenarioQuery query) { @@ -238,6 +247,11 @@ private string[] ResolveScenarioFiles(string path) private async Task LoadAsync(string file) { + if (!_fileSystem.FileExists(file)) + { + throw new UsageException($"Scenario file '{file}' does not exist."); + } + try { await using var stream = _fileSystem.OpenRead(file); diff --git a/Luotsi.Cli/Scenarios/ScenarioExecutor.cs b/Luotsi.Cli/Scenarios/ScenarioExecutor.cs index 96b72d1..803bec2 100644 --- a/Luotsi.Cli/Scenarios/ScenarioExecutor.cs +++ b/Luotsi.Cli/Scenarios/ScenarioExecutor.cs @@ -87,10 +87,9 @@ public sealed class ScenarioExecutor ]; private readonly IScenarioActionHost _actionHost; - private readonly IFileSystem _fileSystem; private readonly TimeProvider _timeProvider; private readonly ScenarioActionDispatcher _actionDispatcher; - private readonly IScenarioTemplateResolver _templateResolver; + private readonly ScenarioCatalog _scenarioCatalog; private readonly IScenarioEventSink _eventSink; public ScenarioExecutor(IScenarioActionHost actionHost, IFileSystem fileSystem, TimeProvider timeProvider, IDelay delay) @@ -120,12 +119,13 @@ public ScenarioExecutor(IScenarioActionHost actionHost, IFileSystem fileSystem, internal ScenarioExecutor(IScenarioActionHost actionHost, IFileSystem fileSystem, TimeProvider timeProvider, IDelay delay, IScenarioTemplateResolver templateResolver, IScenarioEventSink? eventSink = null) { _actionHost = actionHost ?? throw new ArgumentNullException(nameof(actionHost)); - _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _actionDispatcher = new ScenarioActionDispatcher( actionHost, delay ?? throw new ArgumentNullException(nameof(delay))); - _templateResolver = templateResolver ?? throw new ArgumentNullException(nameof(templateResolver)); + _scenarioCatalog = new ScenarioCatalog( + fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)), + templateResolver ?? throw new ArgumentNullException(nameof(templateResolver))); _eventSink = eventSink ?? NullScenarioEventSink.Instance; } @@ -167,8 +167,8 @@ await EmitAsync(new ScenarioEvent( } } - private async Task LoadValidatedScenarioAsync(string file) => - ValidateScenario(ResolveTemplates(await LoadAsync(file).ConfigureAwait(false)), file); + private Task LoadValidatedScenarioAsync(string file) => + _scenarioCatalog.LoadValidatedAsync(file, SupportedScenarioActions); private async Task ExecuteStepAsync(ScenarioStep step, DateTimeOffset? previousStepStartedAt) => await _actionDispatcher.ExecuteAsync(step, previousStepStartedAt).ConfigureAwait(false); @@ -252,36 +252,6 @@ private async Task ExecuteStepsAsync(ScenarioFile scenario, s return new ScenarioExecution(executedStepMs, steps); } - private async Task LoadAsync(string file) - { - if (!_fileSystem.FileExists(file)) - { - throw new UsageException($"Scenario file '{file}' does not exist."); - } - - try - { - await using var stream = _fileSystem.OpenRead(file); - var scenario = await JsonSerializer.DeserializeAsync(stream, AppJson.Options).ConfigureAwait(false); - if (scenario is null) - { - throw new UsageException($"Scenario file '{file}' was empty."); - } - - return scenario; - } - catch (JsonException ex) - { - throw new UsageException($"Scenario file '{file}' is not valid JSON: {ex.Message}"); - } - } - - private ScenarioFile ResolveTemplates(ScenarioFile scenario) => - _templateResolver.ResolveScenario(scenario); - - private static ScenarioFile ValidateScenario(ScenarioFile scenario, string file) => - ScenarioValidator.ValidateScenario(scenario, file, SupportedScenarioActions); - private static ScenarioStepTiming CreateTimingData(ScenarioStep step, double durationMs, int harnessDelayMs) { var configuredDelayMs = GetConfiguredDelayMs(step); diff --git a/Luotsi.Cli/View/Contracts/ViewAbstractions.cs b/Luotsi.Cli/View/Contracts/ViewAbstractions.cs index 77f9f56..b00453c 100644 --- a/Luotsi.Cli/View/Contracts/ViewAbstractions.cs +++ b/Luotsi.Cli/View/Contracts/ViewAbstractions.cs @@ -67,7 +67,8 @@ public sealed record ViewOptions( string? JoinShareEndpoint = null, bool AlwaysOnTop = false, string ScaleMode = "fit", - string CaptureBackend = ViewCaptureBackends.Auto); + string CaptureBackend = ViewCaptureBackends.Auto, + TimeSpan? CommandTimeout = null); /// /// Android view capture backend names. @@ -79,6 +80,32 @@ public static class ViewCaptureBackends public const string MediaProjection = "mediaprojection"; } +/// +/// Device-side view bootstrap phase status. +/// +public static class ViewStartupPhaseStatus +{ + public const string Started = "started"; + public const string Succeeded = "succeeded"; + public const string Failed = "failed"; + public const string Skipped = "skipped"; +} + +/// +/// Machine-readable bootstrap progress for live view startup. +/// +/// Stable phase name. +/// Phase status. +/// Short human-readable summary. +/// Optional detail payload. +/// Optional fix or fallback. +public sealed record ViewStartupPhase( + string Phase, + string Status, + string Summary, + string? Detail = null, + string? Recommendation = null); + /// /// Bootstraps the device-side stream transport. /// @@ -88,9 +115,10 @@ public interface IViewTransportBootstrap /// Starts the transport and returns connection metadata. /// /// Transport start request. + /// Optional bootstrap phase observer. /// Cancellation token. /// Connection metadata. - Task StartAsync(ViewStartRequest request, CancellationToken cancellationToken = default); + Task StartAsync(ViewStartRequest request, Action? reportPhase = null, CancellationToken cancellationToken = default); /// /// Stops the transport. @@ -110,6 +138,7 @@ public interface IViewTransportBootstrap /// Requested video bit rate. /// Requested codec. /// Requested capture backend. +/// Optional bounded ADB command timeout. public sealed record ViewStartRequest( string AdbExecutable, string DeviceSelector, @@ -117,7 +146,8 @@ public sealed record ViewStartRequest( int MaxFps, string VideoBitRate, string Codec, - string CaptureBackend = ViewCaptureBackends.Auto); + string CaptureBackend = ViewCaptureBackends.Auto, + TimeSpan? CommandTimeout = null); /// /// Connection metadata for a view session. diff --git a/Luotsi.Cli/View/Diagnostics/AndroidViewHelperSetupProvisioner.cs b/Luotsi.Cli/View/Diagnostics/AndroidViewHelperSetupProvisioner.cs new file mode 100644 index 0000000..ce0d960 --- /dev/null +++ b/Luotsi.Cli/View/Diagnostics/AndroidViewHelperSetupProvisioner.cs @@ -0,0 +1,123 @@ +using System.Linq; +using Luotsi.Cli.Hosts.Android.View; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Models; +using Luotsi.Cli.View; +using Luotsi.Cli.View.Contracts; + +namespace Luotsi.Cli.View.Diagnostics; + +internal sealed class AndroidViewHelperSetupProvisioner( + IAndroidViewHelperPackageLocator helperPackageLocator, + ViewHostPathResolver pathResolver, + IFileSystem fileSystem, + IProcessRunner processRunner) +{ + private const string HelperProjectDirectory = "Luotsi.ViewServer.Android"; + + private readonly IAndroidViewHelperPackageLocator _helperPackageLocator = helperPackageLocator ?? throw new ArgumentNullException(nameof(helperPackageLocator)); + private readonly ViewHostPathResolver _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + private readonly IProcessRunner _processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + + public async Task ResolveOrBuildAsync(bool fix, Action reportStep, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(reportStep); + + if (TryResolve(reportStep, out var package)) + { + return package; + } + + if (!fix) + { + reportStep(new ViewSetupStep( + "helper_build", + ViewStartupPhaseStatus.Skipped, + "Android view helper build was not attempted.", + null, + "Run view setup --fix or build Luotsi.ViewServer.Android with Gradle.")); + return null; + } + + var projectDirectory = ResolveHelperProjectDirectory(); + if (projectDirectory is null) + { + reportStep(new ViewSetupStep( + "helper_build", + ViewStartupPhaseStatus.Failed, + "Android view helper project was not found.", + HelperProjectDirectory, + "Run this command from the repository root or set LUOTSI_VIEW_HELPER_APK to a built APK.")); + return null; + } + + var wrapper = ResolveGradleWrapper(projectDirectory); + if (wrapper is null) + { + reportStep(new ViewSetupStep( + "helper_build", + ViewStartupPhaseStatus.Failed, + "Gradle wrapper was not found for the Android view helper.", + projectDirectory, + "Build the helper manually and set LUOTSI_VIEW_HELPER_APK.")); + return null; + } + + reportStep(new ViewSetupStep("helper_build", ViewStartupPhaseStatus.Started, "Building Android view helper APK.", projectDirectory)); + var build = await _processRunner.RunAsync(wrapper, ["-p", projectDirectory, ":app:assembleDebug"], cancellationToken).ConfigureAwait(false); + if (build.ExitCode != 0) + { + reportStep(new ViewSetupStep("helper_build", ViewStartupPhaseStatus.Failed, "Android view helper build failed.", PreferError(build), "Fix the Gradle build, then rerun view setup --fix.")); + return null; + } + + reportStep(new ViewSetupStep("helper_build", ViewStartupPhaseStatus.Succeeded, "Android view helper APK built.", build.Stdout.Trim())); + return TryResolve(reportStep, out package) ? package : null; + } + + private bool TryResolve(Action reportStep, out AndroidViewHelperPackage? package) + { + try + { + package = _helperPackageLocator.Resolve(); + reportStep(new ViewSetupStep( + "helper_resolve", + ViewStartupPhaseStatus.Succeeded, + "Android view helper package resolved.", + $"path={package.LocalPath}; source={package.ResolutionSource}; size={package.LocalSizeBytes?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"}; sha256={package.LocalSha256 ?? "unknown"}")); + return true; + } + catch (Exception ex) when (IsExpectedSetupException(ex)) + { + package = null; + reportStep(new ViewSetupStep( + "helper_resolve", + ViewStartupPhaseStatus.Failed, + "Android view helper package is not ready.", + ex.Message, + "Build the helper APK with view setup --fix or set LUOTSI_VIEW_HELPER_APK.")); + return false; + } + } + + private string? ResolveHelperProjectDirectory() + { + var candidates = _pathResolver.GetRepositoryRelativeDirectoryCandidates(HelperProjectDirectory); + return candidates.Where(_fileSystem.DirectoryExists).FirstOrDefault(); + } + + private string? ResolveGradleWrapper(string projectDirectory) + { + string[] candidates = OperatingSystem.IsWindows() + ? [Path.Join(projectDirectory, "gradlew.bat"), Path.Join(projectDirectory, "gradlew")] + : [Path.Join(projectDirectory, "gradlew"), Path.Join(projectDirectory, "gradlew.bat")]; + return candidates.FirstOrDefault(_fileSystem.FileExists); + } + + private static string PreferError(ProcessResult result) => + string.IsNullOrWhiteSpace(result.Stderr) ? result.Stdout.Trim() : result.Stderr.Trim(); + + private static bool IsExpectedSetupException(Exception exception) => + exception is InvalidOperationException or IOException or UnauthorizedAccessException; +} \ No newline at end of file diff --git a/Luotsi.Cli/View/Diagnostics/ViewDoctor.cs b/Luotsi.Cli/View/Diagnostics/ViewDoctor.cs index 07e50c5..1aad1fb 100644 --- a/Luotsi.Cli/View/Diagnostics/ViewDoctor.cs +++ b/Luotsi.Cli/View/Diagnostics/ViewDoctor.cs @@ -277,7 +277,8 @@ private ViewDoctorCheck CheckHelperPackage() try { var package = _helperPackageLocator.Resolve(); - return new ViewDoctorCheck("helper_package", true, $"Android view helper is ready ({package.Version}).", package.LocalPath); + var detail = $"path={package.LocalPath}; source={package.ResolutionSource}; size={package.LocalSizeBytes?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"}; sha256={package.LocalSha256 ?? "unknown"}"; + return new ViewDoctorCheck("helper_package", true, $"Android view helper is ready ({package.Version}).", detail); } catch (Exception ex) { diff --git a/Luotsi.Cli/View/Diagnostics/ViewSetup.cs b/Luotsi.Cli/View/Diagnostics/ViewSetup.cs new file mode 100644 index 0000000..2703ed7 --- /dev/null +++ b/Luotsi.Cli/View/Diagnostics/ViewSetup.cs @@ -0,0 +1,144 @@ +using System.Linq; +using Luotsi.Cli.Hosts.Android.View; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Models; +using Luotsi.Cli.View.Contracts; + +namespace Luotsi.Cli.View.Diagnostics; + +/// +/// Creates setup runners for concrete view hosts. +/// +public interface IViewSetupFactory +{ + IViewSetup Create(IDeviceHost deviceHost); +} + +/// +/// Executes fixable setup checks for live view. +/// +public interface IViewSetup +{ + Task SetupAsync(ViewOptions options, bool fix, CancellationToken cancellationToken = default); +} + +public sealed record ViewSetupStep(string Name, string Status, string Summary, string? Detail = null, string? Recommendation = null); + +public sealed record ViewSetupResult( + bool Ready, + bool Fix, + string Preset, + ViewOptions AppliedOptions, + IReadOnlyList Steps, + ViewDoctorResult Doctor); + +public sealed class DefaultViewSetupFactory( + IEnvironmentVariables environment, + IFileSystem fileSystem, + IProcessRunner processRunner, + IAdbClientFactory adbClientFactory, + IViewDoctorFactory viewDoctorFactory) : IViewSetupFactory +{ + private readonly IEnvironmentVariables _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + private readonly IProcessRunner _processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + private readonly IAdbClientFactory _adbClientFactory = adbClientFactory ?? throw new ArgumentNullException(nameof(adbClientFactory)); + private readonly IViewDoctorFactory _viewDoctorFactory = viewDoctorFactory ?? throw new ArgumentNullException(nameof(viewDoctorFactory)); + + public IViewSetup Create(IDeviceHost deviceHost) + { + var helperPackageLocator = new AndroidViewHelperPackageLocator(_environment, _fileSystem); + return new ViewSetup( + deviceHost, + helperPackageLocator, + new ViewHostPathResolver(_environment), + _viewDoctorFactory, + _fileSystem, + _processRunner, + _adbClientFactory); + } +} + +public sealed class ViewSetup : IViewSetup +{ + private readonly IDeviceHost _deviceHost; + private readonly IAndroidViewHelperPackageLocator _helperPackageLocator; + private readonly AndroidViewHelperSetupProvisioner _helperProvisioner; + private readonly IViewDoctorFactory _viewDoctorFactory; + private readonly IProcessRunner _processRunner; + private readonly IAdbClientFactory _adbClientFactory; + + public ViewSetup( + IDeviceHost deviceHost, + IAndroidViewHelperPackageLocator helperPackageLocator, + ViewHostPathResolver pathResolver, + IViewDoctorFactory viewDoctorFactory, + IFileSystem fileSystem, + IProcessRunner processRunner, + IAdbClientFactory adbClientFactory) + { + _deviceHost = deviceHost ?? throw new ArgumentNullException(nameof(deviceHost)); + _helperPackageLocator = helperPackageLocator ?? throw new ArgumentNullException(nameof(helperPackageLocator)); + ArgumentNullException.ThrowIfNull(pathResolver); + ArgumentNullException.ThrowIfNull(fileSystem); + _processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + _viewDoctorFactory = viewDoctorFactory ?? throw new ArgumentNullException(nameof(viewDoctorFactory)); + _adbClientFactory = adbClientFactory ?? throw new ArgumentNullException(nameof(adbClientFactory)); + _helperProvisioner = new AndroidViewHelperSetupProvisioner( + _helperPackageLocator, + pathResolver, + fileSystem, + _processRunner); + } + + public async Task SetupAsync(ViewOptions options, bool fix, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + var steps = new List(); + var package = await _helperProvisioner.ResolveOrBuildAsync(fix, steps.Add, cancellationToken).ConfigureAwait(false); + if (package is not null) + { + await InstallAndVerifyHelperAsync(options, package, fix, steps, cancellationToken).ConfigureAwait(false); + } + + var doctor = await _viewDoctorFactory.Create(_deviceHost).DiagnoseAsync(options, cancellationToken).ConfigureAwait(false); + var ready = doctor.Ready && steps + .GroupBy(static step => step.Name, StringComparer.Ordinal) + .Select(static group => group.Last()) + .All(static step => !string.Equals(step.Status, ViewStartupPhaseStatus.Failed, StringComparison.Ordinal)); + return new ViewSetupResult(ready, fix, options.PresetName, options, steps, doctor); + } + + private async Task InstallAndVerifyHelperAsync(ViewOptions options, AndroidViewHelperPackage package, bool fix, List steps, CancellationToken cancellationToken) + { + if (!fix) + { + steps.Add(new ViewSetupStep( + "helper_install", + ViewStartupPhaseStatus.Skipped, + "Android view helper install was not attempted.", + package.LocalPath, + "Run view setup --fix to install and verify the helper on the selected device.")); + return; + } + + var adb = _adbClientFactory.Create(options.AdbExecutable, options.DeviceSelector, _processRunner, options.CommandTimeout); + var installer = new AndroidViewServerInstaller( + adb, + _helperPackageLocator, + phase => steps.Add(new ViewSetupStep(phase.Phase, phase.Status, phase.Summary, phase.Detail, phase.Recommendation))); + try + { + await installer.InstallAsync(package, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsExpectedSetupException(ex)) + { + _ = ex; + // The installer already reported the exact helper_install or helper_verify failure phase. + } + } + + private static bool IsExpectedSetupException(Exception exception) => + exception is InvalidOperationException or IOException or UnauthorizedAccessException; +} diff --git a/Luotsi.Cli/View/Session/ViewSession.cs b/Luotsi.Cli/View/Session/ViewSession.cs index eb4ff96..9694dcc 100644 --- a/Luotsi.Cli/View/Session/ViewSession.cs +++ b/Luotsi.Cli/View/Session/ViewSession.cs @@ -40,18 +40,21 @@ public IViewSession Create(IDeviceHost deviceHost, ArtifactSession artifacts) => new ViewSession( deviceHost, artifacts, - _console, - _timeProvider, - new AndroidViewBootstrap( - _adbClientFactory, - _processRunner, - new AndroidViewHelperPackageLocator(_environment, _fileSystem), - _idGenerator), - new DefaultViewBackendFactory(_environment), - new LocalhostViewStreamConnector(), - new ViewPacketStreamReader(), - new DefaultViewRendererFactory(), - new DefaultViewRecorderFactory(_fileSystem, _processRunner, _environment)); + new ViewSessionRuntime + { + Console = _console, + TimeProvider = _timeProvider, + TransportBootstrap = new AndroidViewBootstrap( + _adbClientFactory, + _processRunner, + new AndroidViewHelperPackageLocator(_environment, _fileSystem), + _idGenerator), + ViewBackendFactory = new DefaultViewBackendFactory(_environment), + StreamConnector = new LocalhostViewStreamConnector(), + PacketStreamReader = new ViewPacketStreamReader(), + ViewRendererFactory = new DefaultViewRendererFactory(), + ViewRecorderFactory = new DefaultViewRecorderFactory(_fileSystem, _processRunner, _environment) + }); } /// @@ -104,19 +107,7 @@ private static ViewScaleMode ParseScaleMode(string value) => /// /// Built-in device mirror session. /// -public sealed class ViewSession( - IDeviceHost deviceHost, - ArtifactSession artifacts, - IConsoleIo console, - TimeProvider timeProvider, - IViewTransportBootstrap transportBootstrap, - IViewBackendFactory viewBackendFactory, - IViewStreamConnector streamConnector, - IViewPacketStreamReader packetStreamReader, - IViewRendererFactory? viewRendererFactory = null, - IViewRecorderFactory? viewRecorderFactory = null, - IArtifactFolderOpener? artifactFolderOpener = null, - TimeSpan? autoReconnectAfter = null) : IViewSession +public sealed class ViewSession : IViewSession { private static readonly JsonSerializerOptions OutputJsonOptions = new() { @@ -128,19 +119,40 @@ public sealed class ViewSession( private const int InitialStreamAttempts = 600; private static readonly TimeSpan InitialStreamRetryDelay = TimeSpan.FromMilliseconds(100); private static readonly TimeSpan DefaultAutoReconnectAfter = TimeSpan.FromSeconds(170); - private readonly IDeviceHost _deviceHost = deviceHost ?? throw new ArgumentNullException(nameof(deviceHost)); - private readonly ArtifactSession _artifacts = artifacts ?? throw new ArgumentNullException(nameof(artifacts)); - private readonly IConsoleIo _console = console ?? throw new ArgumentNullException(nameof(console)); - private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - private readonly IViewTransportBootstrap _transportBootstrap = transportBootstrap ?? throw new ArgumentNullException(nameof(transportBootstrap)); - private readonly IViewBackendFactory _viewBackendFactory = viewBackendFactory ?? throw new ArgumentNullException(nameof(viewBackendFactory)); - private readonly IViewStreamConnector _streamConnector = streamConnector ?? throw new ArgumentNullException(nameof(streamConnector)); - private readonly IViewPacketStreamReader _packetStreamReader = packetStreamReader ?? throw new ArgumentNullException(nameof(packetStreamReader)); - private readonly IViewRendererFactory _viewRendererFactory = viewRendererFactory ?? new NullViewRendererFactory(); - private readonly IViewRecorderFactory _viewRecorderFactory = viewRecorderFactory ?? new NullViewRecorderFactory(); - private readonly TimeSpan _autoReconnectAfter = autoReconnectAfter ?? DefaultAutoReconnectAfter; + private readonly IDeviceHost _deviceHost; + private readonly ArtifactSession _artifacts; + private readonly IConsoleIo _console; + private readonly TimeProvider _timeProvider; + private readonly IViewTransportBootstrap _transportBootstrap; + private readonly IViewBackendFactory _viewBackendFactory; + private readonly IViewStreamConnector _streamConnector; + private readonly IViewPacketStreamReader _packetStreamReader; + private readonly IViewRendererFactory _viewRendererFactory; + private readonly IViewRecorderFactory _viewRecorderFactory; + private readonly IArtifactFolderOpener _artifactFolderOpener; + private readonly TimeSpan _autoReconnectAfter; private readonly Lock _writeGate = new(); + public ViewSession(IDeviceHost deviceHost, ArtifactSession artifacts, ViewSessionRuntime runtime) + { + ArgumentNullException.ThrowIfNull(deviceHost); + ArgumentNullException.ThrowIfNull(artifacts); + ArgumentNullException.ThrowIfNull(runtime); + + _deviceHost = deviceHost; + _artifacts = artifacts; + _console = runtime.Console ?? throw new ArgumentNullException(nameof(ViewSessionRuntime.Console)); + _timeProvider = runtime.TimeProvider ?? throw new ArgumentNullException(nameof(ViewSessionRuntime.TimeProvider)); + _transportBootstrap = runtime.TransportBootstrap ?? throw new ArgumentNullException(nameof(ViewSessionRuntime.TransportBootstrap)); + _viewBackendFactory = runtime.ViewBackendFactory ?? throw new ArgumentNullException(nameof(ViewSessionRuntime.ViewBackendFactory)); + _streamConnector = runtime.StreamConnector ?? throw new ArgumentNullException(nameof(ViewSessionRuntime.StreamConnector)); + _packetStreamReader = runtime.PacketStreamReader ?? throw new ArgumentNullException(nameof(ViewSessionRuntime.PacketStreamReader)); + _viewRendererFactory = runtime.ViewRendererFactory ?? new NullViewRendererFactory(); + _viewRecorderFactory = runtime.ViewRecorderFactory ?? new NullViewRecorderFactory(); + _artifactFolderOpener = runtime.ArtifactFolderOpener ?? new SystemArtifactFolderOpener(); + _autoReconnectAfter = runtime.AutoReconnectAfter ?? DefaultAutoReconnectAfter; + } + /// public async Task RunAsync(ViewOptions options, CancellationToken cancellationToken = default) { @@ -157,7 +169,7 @@ public async Task RunAsync(ViewOptions options, CancellationToken cancellat await using var recorder = new SessionControlledViewRecorder(_viewRecorderFactory, options); IViewRenderer? renderer = null; SessionViewRenderer? sessionRenderer = null; - var interactionRouter = new ViewSessionInteractionRouter( + var interactionRouter = new ViewSessionInteractionRouter(new ViewSessionInteractionContext( _deviceHost, _artifacts, options, @@ -165,7 +177,7 @@ public async Task RunAsync(ViewOptions options, CancellationToken cancellat _timeProvider, sessionId, WriteJsonLine, - artifactFolderOpener); + _artifactFolderOpener)); string endReason = "stream_ended"; try { @@ -553,7 +565,7 @@ private async IAsyncEnumerable RelayPacketsAsync( reason }); - var fallback = await StartTransportWithBackendAndReadHeaderAsync(options, activeDeviceSelector, ViewCaptureBackends.Screenrecord, cancellationToken).ConfigureAwait(false); + var fallback = await StartTransportWithBackendAndReadHeaderAsync(options, activeDeviceSelector, ViewCaptureBackends.Screenrecord, sessionId, cancellationToken).ConfigureAwait(false); return (fallback.ConnectionInfo, fallback.Connection, fallback.Header, _packetStreamReader.ReadPacketsAsync(fallback.Connection.Stream, cancellationToken)); } @@ -587,7 +599,11 @@ private static async IAsyncEnumerable PrependPacket( { try { - return await StartTransportWithBackendAndReadHeaderAsync(options, activeDeviceSelector, options.CaptureBackend, cancellationToken).ConfigureAwait(false); + return await StartTransportWithBackendAndReadHeaderAsync(options, activeDeviceSelector, options.CaptureBackend, sessionId, cancellationToken).ConfigureAwait(false); + } + catch (MediaProjectionConsentException ex) when (IsExplicitMediaProjectionRequest(options)) + { + throw new UsageException($"{ex.Message} Use --capture-backend auto or --capture-backend screenrecord."); } catch (Exception ex) when (ShouldFallbackToScreenrecord(options, ex)) { @@ -603,10 +619,13 @@ private static async IAsyncEnumerable PrependPacket( reason = ex.Message }); - return await StartTransportWithBackendAndReadHeaderAsync(options, activeDeviceSelector, ViewCaptureBackends.Screenrecord, cancellationToken).ConfigureAwait(false); + return await StartTransportWithBackendAndReadHeaderAsync(options, activeDeviceSelector, ViewCaptureBackends.Screenrecord, sessionId, cancellationToken).ConfigureAwait(false); } } + private static bool IsExplicitMediaProjectionRequest(ViewOptions options) => + string.Equals(options.CaptureBackend, ViewCaptureBackends.MediaProjection, StringComparison.OrdinalIgnoreCase); + private static bool ShouldFallbackToScreenrecord(ViewOptions options, Exception exception) => string.Equals(options.CaptureBackend, ViewCaptureBackends.Auto, StringComparison.OrdinalIgnoreCase) && exception is not UsageException && @@ -619,6 +638,7 @@ private static bool IsMissingViewHelperPackage(string message) => ViewOptions options, string activeDeviceSelector, string captureBackend, + string sessionId, CancellationToken cancellationToken) { var connectionInfo = await _transportBootstrap.StartAsync( @@ -629,12 +649,29 @@ private static bool IsMissingViewHelperPackage(string message) => options.MaxFps, options.VideoBitRate, options.Codec, - captureBackend), + captureBackend, + options.CommandTimeout), + phase => WriteStartupPhase(sessionId, phase), cancellationToken).ConfigureAwait(false); return await ConnectAndReadHeaderAsync(connectionInfo, cancellationToken).ConfigureAwait(false); } + private void WriteStartupPhase(string sessionId, ViewStartupPhase phase) + { + WriteJsonLine(new + { + type = SessionEventTypes.View.StartupPhase, + session_id = sessionId, + occurred_at = _timeProvider.GetUtcNow(), + phase = phase.Phase, + status = phase.Status, + summary = phase.Summary, + detail = phase.Detail, + recommendation = phase.Recommendation + }); + } + private async Task<(ViewConnectionInfo ConnectionInfo, IViewStreamConnection Connection, ViewStreamHeader Header)> ConnectAndReadHeaderAsync( ViewConnectionInfo connectionInfo, CancellationToken cancellationToken) diff --git a/Luotsi.Cli/View/Session/ViewSessionInputCommandHandler.cs b/Luotsi.Cli/View/Session/ViewSessionInputCommandHandler.cs new file mode 100644 index 0000000..bc9414d --- /dev/null +++ b/Luotsi.Cli/View/Session/ViewSessionInputCommandHandler.cs @@ -0,0 +1,389 @@ +using Luotsi.Cli.Artifacts; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Models; +using Luotsi.Cli.View.Contracts; + +namespace Luotsi.Cli.View.Session; + +internal sealed class ViewSessionInputCommandHandler( + ViewSessionInteractionContext context, + ViewSessionInteractionCallbacks callbacks) +{ + private readonly ViewSessionInteractionContext _context = context ?? throw new ArgumentNullException(nameof(context)); + private readonly IDeviceHost _deviceHost = context.DeviceHost ?? throw new ArgumentNullException(nameof(context.DeviceHost)); + private readonly ArtifactSession _artifacts = context.Artifacts ?? throw new ArgumentNullException(nameof(context.Artifacts)); + private readonly ViewOptions _options = context.Options ?? throw new ArgumentNullException(nameof(context.Options)); + private readonly SessionControlledViewRecorder _recorder = context.Recorder ?? throw new ArgumentNullException(nameof(context.Recorder)); + private readonly TimeProvider _timeProvider = context.TimeProvider ?? throw new ArgumentNullException(nameof(context.TimeProvider)); + private readonly string _sessionId = string.IsNullOrWhiteSpace(context.SessionId) ? throw new ArgumentException("Session id is required.", nameof(context.SessionId)) : context.SessionId; + private readonly Action _writeEvent = context.WriteEvent ?? throw new ArgumentNullException(nameof(context.WriteEvent)); + private readonly Func _publishChromeAsync = callbacks.PublishChromeAsync ?? throw new ArgumentNullException(nameof(callbacks.PublishChromeAsync)); + private readonly Func _activeDeviceSelector = callbacks.ActiveDeviceSelector ?? throw new ArgumentNullException(nameof(callbacks.ActiveDeviceSelector)); + private readonly Func _requestReconnect = callbacks.RequestReconnect ?? throw new ArgumentNullException(nameof(callbacks.RequestReconnect)); + private readonly IArtifactFolderOpener _artifactFolderOpener = context.ArtifactFolderOpener ?? throw new ArgumentNullException(nameof(context.ArtifactFolderOpener)); + + private bool _initialRecordingStarted; + private int _screenshotSequence; + private int _recordingSequence; + private bool _streamPaused; + private Action? _streamPauseUpdater; + + public void AttachConnection(ViewConnectionInfo connectionInfo) => _ = _recorder.InitializeAsync(connectionInfo); + + public void AttachStreamPauseUpdater(Action streamPauseUpdater) => _streamPauseUpdater = streamPauseUpdater; + + public async Task StartInitialRecordingIfNeededAsync() + { + if (_initialRecordingStarted || string.IsNullOrWhiteSpace(_options.RecordPath)) + { + return; + } + + _initialRecordingStarted = true; + await _recorder.StartAsync(_options.RecordPath).ConfigureAwait(false); + await _publishChromeAsync().ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.RecordingStarted, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + record_path = _options.RecordPath, + source = "startup" + }); + } + + public async Task StopRecordingForReconnectAsync() + { + if (!_recorder.IsRecording) + { + return; + } + + var recordPath = _recorder.ActiveRecordPath; + await _recorder.StopAsync().ConfigureAwait(false); + await _publishChromeAsync().ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.RecordingStopped, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + record_path = recordPath, + reason = "reconnect" + }); + } + + public async Task TryHandleAsync(ViewInteractionRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + switch (request) + { + case ViewTapRequest tapRequest: + if (TryBlockReadOnly("tap")) + { + return true; + } + + await _deviceHost.TapPointAsync("view-window", null, null, tapRequest.XRatio, tapRequest.YRatio, 0).ConfigureAwait(false); + return true; + + case ViewWindowCommandRequest windowCommandRequest: + await HandleCommandAsync(windowCommandRequest.Command).ConfigureAwait(false); + return true; + + case ViewTextInputRequest textInputRequest: + if (TryBlockReadOnly("text_input")) + { + return true; + } + + await _deviceHost.TypeTextAsync(textInputRequest.Text).ConfigureAwait(false); + return true; + + case ViewKeyInputRequest keyInputRequest: + if (TryBlockReadOnly("key_input")) + { + return true; + } + + await _deviceHost.KeyEventAsync(keyInputRequest.Code).ConfigureAwait(false); + return true; + + case ViewScrollRequest scrollRequest: + if (TryBlockReadOnly("scroll")) + { + return true; + } + + await _deviceHost.ScrollAsync(scrollRequest.HorizontalTicks, scrollRequest.VerticalTicks).ConfigureAwait(false); + return true; + + case ViewClipboardPasteRequest clipboardPasteRequest: + if (TryBlockReadOnly("clipboard")) + { + return true; + } + + await _deviceHost.TypeTextAsync(clipboardPasteRequest.Text).ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.ClipboardPasted, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + length = clipboardPasteRequest.Text.Length + }); + return true; + + case ViewFileDropRequest fileDropRequest: + if (TryBlockReadOnly("file_drop")) + { + return true; + } + + await HandleFileDropAsync(fileDropRequest.FilePath).ConfigureAwait(false); + return true; + + case ViewFilePullRequest filePullRequest: + if (TryBlockReadOnly("file_pull")) + { + return true; + } + + await HandleFilePullAsync(filePullRequest).ConfigureAwait(false); + return true; + + default: + return false; + } + } + + private async Task HandleCommandAsync(ViewWindowCommand command) + { + switch (command) + { + case ViewWindowCommand.TakeScreenshot: + if (TryBlockUnsupported("screenshot", "observer_session", !string.IsNullOrWhiteSpace(_options.JoinShareEndpoint))) + { + return; + } + + var label = $"view-window-{Interlocked.Increment(ref _screenshotSequence):000}"; + var result = await _deviceHost.TakeScreenshotAsync(label).ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.ScreenshotCaptured, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + label = result.Label, + file = result.File + }); + return; + + case ViewWindowCommand.ToggleRecording: + if (TryBlockUnsupported("recording", "observer_session", !string.IsNullOrWhiteSpace(_options.JoinShareEndpoint))) + { + return; + } + + await ToggleRecordingAsync().ConfigureAwait(false); + return; + + case ViewWindowCommand.Reconnect: + _requestReconnect("operator", null); + return; + + case ViewWindowCommand.Back: + await SendDeviceKeyAsync("KEYCODE_BACK", "back").ConfigureAwait(false); + return; + + case ViewWindowCommand.Home: + await SendDeviceKeyAsync("KEYCODE_HOME", "home").ConfigureAwait(false); + return; + + case ViewWindowCommand.Recents: + await SendDeviceKeyAsync("KEYCODE_APP_SWITCH", "recents").ConfigureAwait(false); + return; + + case ViewWindowCommand.OpenArtifacts: + await _artifactFolderOpener.OpenAsync(_artifacts.Root).ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.ArtifactsOpened, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + artifact_root = _artifacts.Root + }); + return; + + case ViewWindowCommand.Rotate: + await SendDeviceKeyAsync("KEYCODE_ROTATE_SCREEN", "rotate").ConfigureAwait(false); + return; + + case ViewWindowCommand.PauseStream: + _streamPaused = !_streamPaused; + _streamPauseUpdater?.Invoke(_streamPaused); + WriteEvent(new + { + type = _streamPaused ? SessionEventTypes.View.StreamPaused : SessionEventTypes.View.StreamResumed, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + device = _activeDeviceSelector() + }); + return; + + default: + return; + } + } + + private async Task ToggleRecordingAsync() + { + if (_recorder.IsRecording) + { + var recordPath = _recorder.ActiveRecordPath; + await _recorder.StopAsync().ConfigureAwait(false); + await _publishChromeAsync().ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.RecordingStopped, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + record_path = recordPath, + reason = "operator" + }); + return; + } + + var nextPath = BuildNextRecordingPath(); + await _recorder.StartAsync(nextPath).ConfigureAwait(false); + await _publishChromeAsync().ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.RecordingStarted, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + record_path = nextPath, + source = "operator" + }); + } + + private string BuildNextRecordingPath() + { + var sequence = Interlocked.Increment(ref _recordingSequence); + if (sequence == 1 && !string.IsNullOrWhiteSpace(_options.RecordPath) && !_initialRecordingStarted) + { + return _options.RecordPath; + } + + var preferredPath = _options.RecordPath; + var extension = string.IsNullOrWhiteSpace(preferredPath) ? ".h264" : Path.GetExtension(preferredPath); + if (string.IsNullOrWhiteSpace(extension)) + { + extension = ".h264"; + } + + var directory = string.IsNullOrWhiteSpace(preferredPath) + ? _artifacts.Root + : Path.GetDirectoryName(Path.GetFullPath(preferredPath)) ?? _artifacts.Root; + var fileBaseName = string.IsNullOrWhiteSpace(preferredPath) + ? "view-window-record" + : Path.GetFileNameWithoutExtension(preferredPath); + var safeFileName = Path.GetFileName($"{fileBaseName}-{sequence:000}{extension}"); + return Path.Combine(directory, safeFileName); + } + + private async Task HandleFileDropAsync(string filePath) + { + if (string.Equals(Path.GetExtension(filePath), ".apk", StringComparison.OrdinalIgnoreCase)) + { + var installResult = await _deviceHost.InstallPackageAsync(filePath).ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.PackageInstalled, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + package_path = installResult.PackagePath + }); + return; + } + + var pushResult = await _deviceHost.PushFileAsync(filePath).ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.FilePushed, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + local_path = pushResult.LocalPath, + remote_path = pushResult.RemotePath + }); + } + + private async Task HandleFilePullAsync(ViewFilePullRequest request) + { + var pullResult = await _deviceHost.PullFileAsync(request.RemotePath, request.LocalDirectory ?? _artifacts.Root).ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.FilePulled, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + remote_path = pullResult.RemotePath, + local_path = pullResult.LocalPath + }); + } + + private async Task SendDeviceKeyAsync(string keyCode, string command) + { + if (TryBlockReadOnly(command)) + { + return; + } + + await _deviceHost.KeyEventAsync(keyCode).ConfigureAwait(false); + WriteEvent(new + { + type = SessionEventTypes.View.KeyCommandSent, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + command, + code = keyCode + }); + } + + private bool TryBlockReadOnly(string requestType) + { + if (!_options.ReadOnly) + { + return false; + } + + WriteEvent(new + { + type = SessionEventTypes.View.InputBlocked, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + request_type = requestType, + reason = "read_only" + }); + return true; + } + + private bool TryBlockUnsupported(string requestType, string reason, bool unsupported) + { + if (!unsupported) + { + return false; + } + + WriteEvent(new + { + type = SessionEventTypes.View.InputBlocked, + session_id = _sessionId, + occurred_at = _timeProvider.GetUtcNow(), + request_type = requestType, + reason + }); + return true; + } + + private void WriteEvent(object value) => _writeEvent(value); +} \ No newline at end of file diff --git a/Luotsi.Cli/View/Session/ViewSessionInteractionContext.cs b/Luotsi.Cli/View/Session/ViewSessionInteractionContext.cs new file mode 100644 index 0000000..8a26aea --- /dev/null +++ b/Luotsi.Cli/View/Session/ViewSessionInteractionContext.cs @@ -0,0 +1,20 @@ +using Luotsi.Cli.Artifacts; +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.View.Contracts; + +namespace Luotsi.Cli.View.Session; + +internal sealed record ViewSessionInteractionContext( + IDeviceHost DeviceHost, + ArtifactSession Artifacts, + ViewOptions Options, + SessionControlledViewRecorder Recorder, + TimeProvider TimeProvider, + string SessionId, + Action WriteEvent, + IArtifactFolderOpener ArtifactFolderOpener); + +internal sealed record ViewSessionInteractionCallbacks( + Func PublishChromeAsync, + Func ActiveDeviceSelector, + Func RequestReconnect); \ No newline at end of file diff --git a/Luotsi.Cli/View/Session/ViewSessionInteractionRouter.cs b/Luotsi.Cli/View/Session/ViewSessionInteractionRouter.cs index 32b73dc..5b84abb 100644 --- a/Luotsi.Cli/View/Session/ViewSessionInteractionRouter.cs +++ b/Luotsi.Cli/View/Session/ViewSessionInteractionRouter.cs @@ -9,37 +9,26 @@ namespace Luotsi.Cli.View.Session; internal sealed class ViewSessionInteractionRouter( - IDeviceHost deviceHost, - ArtifactSession artifacts, - ViewOptions options, - SessionControlledViewRecorder recorder, - TimeProvider timeProvider, - string sessionId, - Action writeJsonLine, - IArtifactFolderOpener? artifactFolderOpener = null) + ViewSessionInteractionContext context) { - private readonly IDeviceHost _deviceHost = deviceHost ?? throw new ArgumentNullException(nameof(deviceHost)); - private readonly ArtifactSession _artifacts = artifacts ?? throw new ArgumentNullException(nameof(artifacts)); - private readonly ViewOptions _options = options ?? throw new ArgumentNullException(nameof(options)); - private readonly SessionControlledViewRecorder _recorder = recorder ?? throw new ArgumentNullException(nameof(recorder)); - private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - private readonly string _sessionId = string.IsNullOrWhiteSpace(sessionId) ? throw new ArgumentException("Session id is required.", nameof(sessionId)) : sessionId; - private readonly Action _writeJsonLine = writeJsonLine ?? throw new ArgumentNullException(nameof(writeJsonLine)); - private readonly IArtifactFolderOpener _artifactFolderOpener = artifactFolderOpener ?? new SystemArtifactFolderOpener(); + private readonly ViewSessionInteractionContext _context = context ?? throw new ArgumentNullException(nameof(context)); + private readonly IDeviceHost _deviceHost = context.DeviceHost ?? throw new ArgumentNullException(nameof(context.DeviceHost)); + private readonly ArtifactSession _artifacts = context.Artifacts ?? throw new ArgumentNullException(nameof(context.Artifacts)); + private readonly ViewOptions _options = context.Options ?? throw new ArgumentNullException(nameof(context.Options)); + private readonly SessionControlledViewRecorder _recorder = context.Recorder ?? throw new ArgumentNullException(nameof(context.Recorder)); + private readonly TimeProvider _timeProvider = context.TimeProvider ?? throw new ArgumentNullException(nameof(context.TimeProvider)); + private readonly string _sessionId = string.IsNullOrWhiteSpace(context.SessionId) ? throw new ArgumentException("Session id is required.", nameof(context.SessionId)) : context.SessionId; + private readonly Action _writeJsonLine = context.WriteEvent ?? throw new ArgumentNullException(nameof(context.WriteEvent)); + private readonly IArtifactFolderOpener _artifactFolderOpener = context.ArtifactFolderOpener ?? throw new ArgumentNullException(nameof(context.ArtifactFolderOpener)); private CancellationTokenSource? _iterationCancellation; private TaskCompletionSource _reconnectRequested = new(TaskCreationOptions.RunContinuationsAsynchronously); - private bool _initialRecordingStarted; - private int _screenshotSequence; - private int _recordingSequence; - private bool _streamPaused; private Func? _chromeUpdater; - private Action? _streamPauseUpdater; private IReadOnlyList _devices = []; - private string? _shareEndpoint = options.JoinShareEndpoint; + private string? _shareEndpoint = context.Options.JoinShareEndpoint; private int _observerCount; - public string ActiveDeviceSelector { get; private set; } = options.DeviceSelector; + public string ActiveDeviceSelector { get; private set; } = context.Options.DeviceSelector; public void BeginIteration(string deviceSelector, CancellationTokenSource iterationCancellation) { @@ -48,11 +37,11 @@ public void BeginIteration(string deviceSelector, CancellationTokenSource iterat UpdateActiveDeviceFlags(); } - public void AttachConnection(ViewConnectionInfo connectionInfo) => _ = _recorder.InitializeAsync(connectionInfo); + public void AttachConnection(ViewConnectionInfo connectionInfo) => InputCommands.AttachConnection(connectionInfo); public void AttachChromeUpdater(Func chromeUpdater) => _chromeUpdater = chromeUpdater; - public void AttachStreamPauseUpdater(Action streamPauseUpdater) => _streamPauseUpdater = streamPauseUpdater; + public void AttachStreamPauseUpdater(Action streamPauseUpdater) => InputCommands.AttachStreamPauseUpdater(streamPauseUpdater); public Task WaitForReconnectAsync() => _reconnectRequested.Task; @@ -80,44 +69,10 @@ public bool RequestReconnect(string source, string? reason = null) } public async Task StartInitialRecordingIfNeededAsync() - { - if (_initialRecordingStarted || string.IsNullOrWhiteSpace(_options.RecordPath)) - { - return; - } - - _initialRecordingStarted = true; - await _recorder.StartAsync(_options.RecordPath).ConfigureAwait(false); - await PublishChromeAsync().ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.RecordingStarted, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - record_path = _options.RecordPath, - source = "startup" - }); - } + => await InputCommands.StartInitialRecordingIfNeededAsync().ConfigureAwait(false); public async Task StopRecordingForReconnectAsync() - { - if (!_recorder.IsRecording) - { - return; - } - - var recordPath = _recorder.ActiveRecordPath; - await _recorder.StopAsync().ConfigureAwait(false); - await PublishChromeAsync().ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.RecordingStopped, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - record_path = recordPath, - reason = "reconnect" - }); - } + => await InputCommands.StopRecordingForReconnectAsync().ConfigureAwait(false); public async Task HandleAsync(ViewInteractionRequest request) { @@ -125,83 +80,9 @@ public async Task HandleAsync(ViewInteractionRequest request) switch (request) { - case ViewTapRequest tapRequest: - if (TryBlockReadOnly("tap")) - { - return; - } - - await _deviceHost.TapPointAsync("view-window", null, null, tapRequest.XRatio, tapRequest.YRatio, 0).ConfigureAwait(false); - break; - - case ViewWindowCommandRequest windowCommandRequest: - await HandleCommandAsync(windowCommandRequest.Command).ConfigureAwait(false); - break; - - case ViewTextInputRequest textInputRequest: - if (TryBlockReadOnly("text_input")) - { - return; - } - - await _deviceHost.TypeTextAsync(textInputRequest.Text).ConfigureAwait(false); - break; - - case ViewKeyInputRequest keyInputRequest: - if (TryBlockReadOnly("key_input")) - { - return; - } - - await _deviceHost.KeyEventAsync(keyInputRequest.Code).ConfigureAwait(false); - break; - - case ViewScrollRequest scrollRequest: - if (TryBlockReadOnly("scroll")) - { - return; - } - - await _deviceHost.ScrollAsync(scrollRequest.HorizontalTicks, scrollRequest.VerticalTicks).ConfigureAwait(false); - break; - - case ViewClipboardPasteRequest clipboardPasteRequest: - if (TryBlockReadOnly("clipboard")) - { - return; - } - - await _deviceHost.TypeTextAsync(clipboardPasteRequest.Text).ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.ClipboardPasted, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - length = clipboardPasteRequest.Text.Length - }); - break; - - case ViewFileDropRequest fileDropRequest: - if (TryBlockReadOnly("file_drop")) - { - return; - } - - await HandleFileDropAsync(fileDropRequest.FilePath).ConfigureAwait(false); - break; - - case ViewFilePullRequest filePullRequest: - if (TryBlockReadOnly("file_pull")) - { - return; - } - - await HandleFilePullAsync(filePullRequest).ConfigureAwait(false); - break; - case ViewSwitchDeviceRequest switchDeviceRequest: await HandleDeviceSwitchAsync(switchDeviceRequest).ConfigureAwait(false); - break; + return; case ViewInteractionFailedRequest failedRequest: WriteEvent(new @@ -213,9 +94,14 @@ public async Task HandleAsync(ViewInteractionRequest request) exception_type = failedRequest.ExceptionType, message = failedRequest.Message }); - break; + return; default: + if (await InputCommands.TryHandleAsync(request).ConfigureAwait(false)) + { + return; + } + throw new InvalidOperationException($"Unsupported view interaction request '{request.GetType().Name}'."); } } @@ -251,196 +137,6 @@ private async Task HandleDeviceSwitchAsync(ViewSwitchDeviceRequest request) } } - private async Task HandleCommandAsync(ViewWindowCommand command) - { - switch (command) - { - case ViewWindowCommand.TakeScreenshot: - if (TryBlockUnsupported("screenshot", "observer_session", !string.IsNullOrWhiteSpace(_options.JoinShareEndpoint))) - { - break; - } - - { - var label = $"view-window-{Interlocked.Increment(ref _screenshotSequence):000}"; - var result = await _deviceHost.TakeScreenshotAsync(label).ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.ScreenshotCaptured, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - label = result.Label, - file = result.File - }); - break; - } - - case ViewWindowCommand.ToggleRecording: - if (TryBlockUnsupported("recording", "observer_session", !string.IsNullOrWhiteSpace(_options.JoinShareEndpoint))) - { - break; - } - - await ToggleRecordingAsync().ConfigureAwait(false); - break; - - case ViewWindowCommand.Reconnect: - RequestReconnect("operator"); - break; - - case ViewWindowCommand.Back: - await SendDeviceKeyAsync("KEYCODE_BACK", "back").ConfigureAwait(false); - break; - - case ViewWindowCommand.Home: - await SendDeviceKeyAsync("KEYCODE_HOME", "home").ConfigureAwait(false); - break; - - case ViewWindowCommand.Recents: - await SendDeviceKeyAsync("KEYCODE_APP_SWITCH", "recents").ConfigureAwait(false); - break; - - case ViewWindowCommand.OpenArtifacts: - await _artifactFolderOpener.OpenAsync(_artifacts.Root).ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.ArtifactsOpened, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - artifact_root = _artifacts.Root - }); - break; - - case ViewWindowCommand.Rotate: - await SendDeviceKeyAsync("KEYCODE_ROTATE_SCREEN", "rotate").ConfigureAwait(false); - break; - - case ViewWindowCommand.PauseStream: - _streamPaused = !_streamPaused; - _streamPauseUpdater?.Invoke(_streamPaused); - WriteEvent(new - { - type = _streamPaused ? SessionEventTypes.View.StreamPaused : SessionEventTypes.View.StreamResumed, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - device = ActiveDeviceSelector - }); - break; - } - } - - private async Task ToggleRecordingAsync() - { - if (_recorder.IsRecording) - { - var recordPath = _recorder.ActiveRecordPath; - await _recorder.StopAsync().ConfigureAwait(false); - await PublishChromeAsync().ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.RecordingStopped, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - record_path = recordPath, - reason = "operator" - }); - return; - } - - var nextPath = BuildNextRecordingPath(); - await _recorder.StartAsync(nextPath).ConfigureAwait(false); - await PublishChromeAsync().ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.RecordingStarted, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - record_path = nextPath, - source = "operator" - }); - } - - private string BuildNextRecordingPath() - { - var sequence = Interlocked.Increment(ref _recordingSequence); - if (sequence == 1 && !string.IsNullOrWhiteSpace(_options.RecordPath) && !_initialRecordingStarted) - { - return _options.RecordPath; - } - - var preferredPath = _options.RecordPath; - var extension = string.IsNullOrWhiteSpace(preferredPath) ? ".h264" : Path.GetExtension(preferredPath); - if (string.IsNullOrWhiteSpace(extension)) - { - extension = ".h264"; - } - - var directory = string.IsNullOrWhiteSpace(preferredPath) - ? _artifacts.Root - : Path.GetDirectoryName(Path.GetFullPath(preferredPath)) ?? _artifacts.Root; - var fileBaseName = string.IsNullOrWhiteSpace(preferredPath) - ? "view-window-record" - : Path.GetFileNameWithoutExtension(preferredPath); - return Path.Combine(directory, $"{fileBaseName}-{sequence:000}{extension}"); - } - - private async Task HandleFileDropAsync(string filePath) - { - if (string.Equals(Path.GetExtension(filePath), ".apk", StringComparison.OrdinalIgnoreCase)) - { - var installResult = await _deviceHost.InstallPackageAsync(filePath).ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.PackageInstalled, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - package_path = installResult.PackagePath - }); - return; - } - - var pushResult = await _deviceHost.PushFileAsync(filePath).ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.FilePushed, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - local_path = pushResult.LocalPath, - remote_path = pushResult.RemotePath - }); - } - - private async Task HandleFilePullAsync(ViewFilePullRequest request) - { - var pullResult = await _deviceHost.PullFileAsync(request.RemotePath, request.LocalDirectory ?? _artifacts.Root).ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.FilePulled, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - remote_path = pullResult.RemotePath, - local_path = pullResult.LocalPath - }); - } - - private async Task SendDeviceKeyAsync(string keyCode, string command) - { - if (TryBlockReadOnly(command)) - { - return; - } - - await _deviceHost.KeyEventAsync(keyCode).ConfigureAwait(false); - WriteEvent(new - { - type = SessionEventTypes.View.KeyCommandSent, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - command, - code = keyCode - }); - } - public async Task EmitDeviceShelfSnapshotIfNeededAsync() { if (!string.IsNullOrWhiteSpace(_options.JoinShareEndpoint)) @@ -520,41 +216,13 @@ private void UpdateActiveDeviceFlags() .ToArray(); } - private bool TryBlockReadOnly(string requestType) - { - if (!_options.ReadOnly) - { - return false; - } - - WriteEvent(new - { - type = SessionEventTypes.View.InputBlocked, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - request_type = requestType, - reason = "read_only" - }); - return true; - } - - private bool TryBlockUnsupported(string requestType, string reason, bool unsupported) - { - if (!unsupported) - { - return false; - } - - WriteEvent(new - { - type = SessionEventTypes.View.InputBlocked, - session_id = _sessionId, - occurred_at = _timeProvider.GetUtcNow(), - request_type = requestType, - reason - }); - return true; - } + private ViewSessionInputCommandHandler InputCommands => + field ??= new ViewSessionInputCommandHandler( + _context, + new ViewSessionInteractionCallbacks( + PublishChromeAsync, + () => ActiveDeviceSelector, + RequestReconnect)); private void WriteEvent(object value) => _writeJsonLine(value); } diff --git a/Luotsi.Cli/View/Session/ViewSessionRuntime.cs b/Luotsi.Cli/View/Session/ViewSessionRuntime.cs new file mode 100644 index 0000000..edc69f0 --- /dev/null +++ b/Luotsi.Cli/View/Session/ViewSessionRuntime.cs @@ -0,0 +1,30 @@ +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.View.Contracts; + +namespace Luotsi.Cli.View.Session; + +/// +/// Describes the runtime collaborators used by a view session. +/// +public sealed record ViewSessionRuntime +{ + public required IConsoleIo Console { get; init; } + + public required TimeProvider TimeProvider { get; init; } + + public required IViewTransportBootstrap TransportBootstrap { get; init; } + + public required IViewBackendFactory ViewBackendFactory { get; init; } + + public required IViewStreamConnector StreamConnector { get; init; } + + public required IViewPacketStreamReader PacketStreamReader { get; init; } + + public IViewRendererFactory? ViewRendererFactory { get; init; } + + public IViewRecorderFactory? ViewRecorderFactory { get; init; } + + public IArtifactFolderOpener? ArtifactFolderOpener { get; init; } + + public TimeSpan? AutoReconnectAfter { get; init; } +} \ No newline at end of file diff --git a/Luotsi.Cli/View/Transport/NullViewTransport.cs b/Luotsi.Cli/View/Transport/NullViewTransport.cs index e7acf1c..9fbff2a 100644 --- a/Luotsi.Cli/View/Transport/NullViewTransport.cs +++ b/Luotsi.Cli/View/Transport/NullViewTransport.cs @@ -4,7 +4,7 @@ namespace Luotsi.Cli.View.Transport; internal sealed class NullViewTransportBootstrap : IViewTransportBootstrap { - public Task StartAsync(ViewStartRequest request, CancellationToken cancellationToken = default) => + public Task StartAsync(ViewStartRequest request, Action? reportPhase = null, CancellationToken cancellationToken = default) => Task.FromResult(new ViewConnectionInfo( Guid.NewGuid().ToString("N"), request.Codec, diff --git a/Luotsi.Cli/View/ViewHostPathResolver.cs b/Luotsi.Cli/View/ViewHostPathResolver.cs index 8710150..6fe5780 100644 --- a/Luotsi.Cli/View/ViewHostPathResolver.cs +++ b/Luotsi.Cli/View/ViewHostPathResolver.cs @@ -18,22 +18,17 @@ public sealed class ViewHostPathResolver(IEnvironmentVariables environment) /// Candidate host-local file paths. public IEnumerable GetRepositoryRelativeFileCandidates(string relativePath) { - ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); - if (Path.IsPathRooted(relativePath)) - { - throw new ArgumentException("Path must be repository-relative.", nameof(relativePath)); - } + return GetRepositoryRelativePathCandidates(relativePath); + } - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var buildOutputRepositoryRoot = Path.GetFullPath(Path.Join(AppContext.BaseDirectory, "..", "..", "..", "..")); - foreach (var candidate in new[] - { - Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), relativePath)), - Path.GetFullPath(Path.Join(buildOutputRepositoryRoot, relativePath)) - }.Where(seen.Add)) - { - yield return candidate; - } + /// + /// Enumerates repository-relative directory candidates rooted at the current working directory and test/build output. + /// + /// Repository-relative path to probe. + /// Candidate host-local directory paths. + public IEnumerable GetRepositoryRelativeDirectoryCandidates(string relativePath) + { + return GetRepositoryRelativePathCandidates(relativePath); } /// @@ -154,6 +149,26 @@ private static IEnumerable GetProcessRelativeDirectoryCandidates(string yield return Path.GetFullPath(Path.Join(AppContext.BaseDirectory, relativePath)); } + private static IEnumerable GetRepositoryRelativePathCandidates(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + if (Path.IsPathRooted(relativePath)) + { + throw new ArgumentException("Path must be repository-relative.", nameof(relativePath)); + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var buildOutputRepositoryRoot = Path.GetFullPath(Path.Join(AppContext.BaseDirectory, "..", "..", "..", "..")); + foreach (var candidate in new[] + { + Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), relativePath)), + Path.GetFullPath(Path.Join(buildOutputRepositoryRoot, relativePath)) + }.Where(seen.Add)) + { + yield return candidate; + } + } + private static bool IsBinDirectory(string path) => string.Equals(new DirectoryInfo(path).Name, "bin", StringComparison.OrdinalIgnoreCase); } diff --git a/scripts/luotsi.ps1 b/scripts/luotsi.ps1 new file mode 100644 index 0000000..7cf7c6a --- /dev/null +++ b/scripts/luotsi.ps1 @@ -0,0 +1,11 @@ +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $LuotsiArgs +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$project = Join-Path $repoRoot 'Luotsi.Cli' + +dotnet run --project $project -- @LuotsiArgs +exit $LASTEXITCODE diff --git a/scripts/luotsi.sh b/scripts/luotsi.sh new file mode 100755 index 0000000..477486f --- /dev/null +++ b/scripts/luotsi.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(dirname -- "$script_dir") + +exec dotnet run --project "$repo_root/Luotsi.Cli" -- "$@"