diff --git a/docs/dotnet-sdk-management.md b/docs/dotnet-sdk-management.md index 2c1b58e..21c841e 100644 --- a/docs/dotnet-sdk-management.md +++ b/docs/dotnet-sdk-management.md @@ -162,6 +162,8 @@ participate in resolution. - **Terminal Mode** is `--set-default-install` on install (writes the shell profile). Sherpa's one-step path is `sdk install --set-default-install`. On Apple platforms, Sherpa hosts dotnetup in a pseudo-terminal and streams its live ANSI progress into the terminal panel. + When an SDK install opens dotnetup's recommended-settings wizard there, the process dialog accepts + raw terminal input so arrow keys, Enter, and other control sequences reach dotnetup unchanged. Redirected platforms keep using `--no-progress` and receive phase-by-phase output. - Always resolve the managed install root from `list --format Json` `installRoot` rather than hardcoding a path — it differs per OS. diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 4734c8c..44641bb 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -2046,7 +2046,8 @@ Task EnsureInstalledAsync( /// Builds a for arbitrary dotnetup arguments. ProcessRequest CreateProcessRequest( IReadOnlyList arguments, string? title = null, string? description = null, - string? workingDirectory = null, bool usePseudoTerminal = false); + string? workingDirectory = null, bool usePseudoTerminal = false, + bool acceptsStandardInput = false); /// /// Builds a request that installs the SDK required by the global.json in @@ -2112,7 +2113,8 @@ public record ProcessRequest( string? Description = null, bool UsePseudoTerminal = false, string? ConfirmationDetails = null, - string? ConfirmationButtonText = null + string? ConfirmationButtonText = null, + bool AcceptsStandardInput = false ) { /// @@ -2202,6 +2204,12 @@ public interface IProcessExecutionService /// Executes a process and returns the result when complete /// Task ExecuteAsync(ProcessRequest request, CancellationToken cancellationToken = default); + + /// + /// Sends raw input to an active process that opted into standard input. + /// Returns false when the process is no longer able to accept input. + /// + Task SendInputAsync(string data, CancellationToken cancellationToken = default); /// /// Sends a graceful cancellation signal (SIGINT/Ctrl+C) diff --git a/src/MauiSherpa.Core/Services/DotnetUpService.cs b/src/MauiSherpa.Core/Services/DotnetUpService.cs index b7f4a5f..296b6ae 100644 --- a/src/MauiSherpa.Core/Services/DotnetUpService.cs +++ b/src/MauiSherpa.Core/Services/DotnetUpService.cs @@ -309,7 +309,8 @@ public async Task InspectProjectFolderAsync( public ProcessRequest CreateProcessRequest( IReadOnlyList arguments, string? title = null, string? description = null, - string? workingDirectory = null, bool usePseudoTerminal = false) => + string? workingDirectory = null, bool usePseudoTerminal = false, + bool acceptsStandardInput = false) => new( Command: ExecutablePath, Arguments: arguments.ToArray(), @@ -319,7 +320,8 @@ public ProcessRequest CreateProcessRequest( Environment: null, Title: title, Description: description, - UsePseudoTerminal: usePseudoTerminal); + UsePseudoTerminal: usePseudoTerminal, + AcceptsStandardInput: acceptsStandardInput); private static bool SupportsTerminalProgress => OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst(); @@ -348,7 +350,8 @@ public ProcessRequest InstallSdkRequest(string? channel = null, bool terminalMod description: channel is null ? "Installing the latest .NET SDK via dotnetup" : $"Installing .NET SDK channel '{channel}' via dotnetup", - usePseudoTerminal: SupportsTerminalProgress); + usePseudoTerminal: SupportsTerminalProgress, + acceptsStandardInput: SupportsTerminalProgress); public ProcessRequest UpdateSdksRequest() => CreateProcessRequest( diff --git a/src/MauiSherpa/Components/ProcessExecutionModal.razor b/src/MauiSherpa/Components/ProcessExecutionModal.razor index d4913d7..36b5a8a 100644 --- a/src/MauiSherpa/Components/ProcessExecutionModal.razor +++ b/src/MauiSherpa/Components/ProcessExecutionModal.razor @@ -360,6 +360,8 @@ private bool CanCancel => ProcessService.CurrentState == ProcessState.Running; private bool CanKill => ProcessService.CurrentState == ProcessState.Running || ProcessService.CurrentState == ProcessState.Cancelled; + private bool CanAcceptInput => + Request is { AcceptsStandardInput: true, RequiresElevation: false }; protected override void OnInitialized() { @@ -387,7 +389,11 @@ if (_needsCompletedTerminal && Result != null) { _needsCompletedTerminal = false; - await JS.InvokeVoidAsync("terminalInterop.initialize", "process-terminal", new { }); + await JS.InvokeVoidAsync( + "terminalInterop.initialize", + "process-terminal", + new { disableStdin = true, cursorBlink = false }, + null); _terminalInitialized = true; // Replay stdout @@ -431,6 +437,15 @@ HandleClose(); } + [JSInvokable] + public async Task OnProcessInput(string data) + { + if (!CanAcceptInput || string.IsNullOrEmpty(data)) + return; + + await ProcessService.SendInputAsync(data); + } + private void HandleShowRequested(ProcessRequest request) { CurrentView = ModalService.RequiresConfirmation ? ModalView.Confirmation : ModalView.Running; @@ -483,7 +498,15 @@ if (Request == null) return; // Initialize terminal - await JS.InvokeVoidAsync("terminalInterop.initialize", "process-terminal", new { }); + await JS.InvokeVoidAsync( + "terminalInterop.initialize", + "process-terminal", + new + { + disableStdin = !CanAcceptInput, + cursorBlink = CanAcceptInput + }, + CanAcceptInput ? _dotNetRef : null); _terminalInitialized = true; // Start elapsed timer @@ -514,6 +537,12 @@ _elapsedTimer = null; } + if (_terminalInitialized) + { + await JS.InvokeVoidAsync("terminalInterop.dispose", "process-terminal"); + _terminalInitialized = false; + } + // Transition to completed view — terminal will be re-initialized in OnAfterRenderAsync _needsCompletedTerminal = true; CurrentView = ModalView.Completed; diff --git a/src/MauiSherpa/Services/ProcessExecutionService.cs b/src/MauiSherpa/Services/ProcessExecutionService.cs index dbbdc58..1b16be8 100644 --- a/src/MauiSherpa/Services/ProcessExecutionService.cs +++ b/src/MauiSherpa/Services/ProcessExecutionService.cs @@ -19,6 +19,8 @@ public class ProcessExecutionService : IProcessExecutionService private DateTime _startTime; private string? _tempOutputFile; private CancellationTokenSource? _tailCts; + private readonly SemaphoreSlim _inputWriteLock = new(1, 1); + private bool _acceptsStandardInput; public ProcessState CurrentState { @@ -51,8 +53,19 @@ public ProcessExecutionService(ILoggingService logger, IPlatformService platform public async Task ExecuteAsync(ProcessRequest request, CancellationToken cancellationToken = default) { + if (request.AcceptsStandardInput && request.RequiresElevation) + { + throw new ArgumentException( + "Interactive standard input is not supported for elevated processes.", + nameof(request)); + } + _outputBuilder.Clear(); _errorBuilder.Clear(); + lock (_stateLock) + { + _acceptsStandardInput = request.AcceptsStandardInput; + } CurrentState = ProcessState.Running; _startTime = DateTime.Now; @@ -98,8 +111,20 @@ public async Task ExecuteAsync(ProcessRequest request, Cancellati CleanupTempFiles(); _linkedCts?.Dispose(); _linkedCts = null; - _currentProcess?.Dispose(); - _currentProcess = null; + await _inputWriteLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + lock (_stateLock) + { + _acceptsStandardInput = false; + _currentProcess?.Dispose(); + _currentProcess = null; + } + } + finally + { + _inputWriteLock.Release(); + } _tailCts?.Cancel(); _tailCts?.Dispose(); _tailCts = null; @@ -549,9 +574,20 @@ public void Cancel() } else { - // On Windows, try to send Ctrl+C - _currentProcess.StandardInput.WriteLine("\x03"); - _currentProcess.StandardInput.Close(); + // Serialize cancellation with interactive writes so stdin cannot be closed mid-write. + _inputWriteLock.Wait(); + try + { + if (_currentProcess is { HasExited: false } process) + { + process.StandardInput.WriteLine("\x03"); + process.StandardInput.Close(); + } + } + finally + { + _inputWriteLock.Release(); + } } // Start a safety timeout: if the process hasn't exited after 30s, @@ -604,6 +640,71 @@ public string GetFullOutput() return _outputBuilder.ToString(); } + public async Task SendInputAsync( + string data, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(data)) + return true; + + Process? process; + lock (_stateLock) + { + if (!_acceptsStandardInput || + _currentState != ProcessState.Running || + _currentProcess == null) + { + return false; + } + + process = _currentProcess; + } + + await _inputWriteLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + lock (_stateLock) + { + if (!_acceptsStandardInput || + _currentState != ProcessState.Running || + !ReferenceEquals(process, _currentProcess)) + { + return false; + } + } + + if (process.HasExited || !process.StartInfo.RedirectStandardInput) + return false; + + try + { + await process.StandardInput + .WriteAsync(data.AsMemory(), cancellationToken) + .ConfigureAwait(false); + await process.StandardInput + .FlushAsync(cancellationToken) + .ConfigureAwait(false); + return true; + } + catch (IOException) + { + return false; + } + catch (ObjectDisposedException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + finally + { + _inputWriteLock.Release(); + } + } + private void OnOutput(string data, bool isError, bool isRaw = false) { if (isError) diff --git a/src/MauiSherpa/wwwroot/js/terminal.js b/src/MauiSherpa/wwwroot/js/terminal.js index 1053d24..2e81006 100644 --- a/src/MauiSherpa/wwwroot/js/terminal.js +++ b/src/MauiSherpa/wwwroot/js/terminal.js @@ -5,7 +5,7 @@ window.terminalInterop = { /** * Initialize a terminal in the specified container */ - initialize: function (containerId, options) { + initialize: function (containerId, options, dotnetRef) { const container = document.getElementById(containerId); if (!container) { console.error('Terminal container not found:', containerId); @@ -53,11 +53,24 @@ window.terminalInterop = { fitAddon.fit(); // Store reference - this.terminals[containerId] = { + const state = { terminal: terminal, fitAddon: fitAddon, - autoScroll: true + autoScroll: true, + inputSubscription: null, + resizeObserver: null, + inputQueue: Promise.resolve() }; + this.terminals[containerId] = state; + + if (dotnetRef) { + state.inputSubscription = terminal.onData(data => { + state.inputQueue = state.inputQueue + .then(() => dotnetRef.invokeMethodAsync('OnProcessInput', data)) + .catch(error => console.error('Failed to forward terminal input:', error)); + }); + terminal.focus(); + } // Handle resize const resizeObserver = new ResizeObserver(() => { @@ -68,6 +81,7 @@ window.terminalInterop = { } }); resizeObserver.observe(container); + state.resizeObserver = resizeObserver; // Track user scroll to disable auto-scroll terminal.element.addEventListener('wheel', () => { @@ -469,6 +483,8 @@ window.terminalInterop = { const t = this.terminals[containerId]; if (!t) return; + t.inputSubscription?.dispose(); + t.resizeObserver?.disconnect(); t.terminal.dispose(); delete this.terminals[containerId]; } diff --git a/tests/MauiSherpa.Core.Tests/Services/DotnetUpServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/DotnetUpServiceTests.cs index 90c595a..0508b9b 100644 --- a/tests/MauiSherpa.Core.Tests/Services/DotnetUpServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/DotnetUpServiceTests.cs @@ -1,11 +1,43 @@ using FluentAssertions; +using MauiSherpa.Core.Interfaces; using MauiSherpa.Core.Services; using MauiSherpa.Workloads.Models; +using Moq; namespace MauiSherpa.Core.Tests.Services; public class DotnetUpServiceTests { + [Fact] + public void InstallSdkRequestWithoutChannelAcceptsStandardInputWhenPseudoTerminalIsAvailable() + { + var service = new DotnetUpService(Mock.Of()); + + var request = service.InstallSdkRequest(); + + request.AcceptsStandardInput.Should().Be(OperatingSystem.IsMacOS()); + } + + [Fact] + public void TrackedChannelInstallAcceptsStandardInputWhenPseudoTerminalIsAvailable() + { + var service = new DotnetUpService(Mock.Of()); + + var request = service.InstallSdkRequest("10.0.3xx", terminalMode: false); + + request.AcceptsStandardInput.Should().Be(OperatingSystem.IsMacOS()); + } + + [Fact] + public void CreateProcessRequestDefaultsToOutputOnly() + { + var service = new DotnetUpService(Mock.Of()); + + var request = service.CreateProcessRequest(["--info"]); + + request.AcceptsStandardInput.Should().BeFalse(); + } + [Fact] public void SelectInstalledSdkTargetRejectsAmbiguousRoots() {