Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/dotnet-sdk-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <channel> --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.
Expand Down
12 changes: 10 additions & 2 deletions src/MauiSherpa.Core/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2046,7 +2046,8 @@ Task<bool> EnsureInstalledAsync(
/// <summary>Builds a <see cref="ProcessRequest"/> for arbitrary dotnetup arguments.</summary>
ProcessRequest CreateProcessRequest(
IReadOnlyList<string> arguments, string? title = null, string? description = null,
string? workingDirectory = null, bool usePseudoTerminal = false);
string? workingDirectory = null, bool usePseudoTerminal = false,
bool acceptsStandardInput = false);

/// <summary>
/// Builds a request that installs the SDK required by the <c>global.json</c> in
Expand Down Expand Up @@ -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
)
{
/// <summary>
Expand Down Expand Up @@ -2202,6 +2204,12 @@ public interface IProcessExecutionService
/// Executes a process and returns the result when complete
/// </summary>
Task<ProcessResult> ExecuteAsync(ProcessRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Sends raw input to an active process that opted into standard input.
/// Returns false when the process is no longer able to accept input.
/// </summary>
Task<bool> SendInputAsync(string data, CancellationToken cancellationToken = default);

/// <summary>
/// Sends a graceful cancellation signal (SIGINT/Ctrl+C)
Expand Down
9 changes: 6 additions & 3 deletions src/MauiSherpa.Core/Services/DotnetUpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ public async Task<GlobalJsonResolution> InspectProjectFolderAsync(

public ProcessRequest CreateProcessRequest(
IReadOnlyList<string> 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(),
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 31 additions & 2 deletions src/MauiSherpa/Components/ProcessExecutionModal.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
111 changes: 106 additions & 5 deletions src/MauiSherpa/Services/ProcessExecutionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -51,8 +53,19 @@ public ProcessExecutionService(ILoggingService logger, IPlatformService platform

public async Task<ProcessResult> 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;

Expand Down Expand Up @@ -98,8 +111,20 @@ public async Task<ProcessResult> 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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -604,6 +640,71 @@ public string GetFullOutput()
return _outputBuilder.ToString();
}

public async Task<bool> 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)
Expand Down
22 changes: 19 additions & 3 deletions src/MauiSherpa/wwwroot/js/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand All @@ -68,6 +81,7 @@ window.terminalInterop = {
}
});
resizeObserver.observe(container);
state.resizeObserver = resizeObserver;

// Track user scroll to disable auto-scroll
terminal.element.addEventListener('wheel', () => {
Expand Down Expand Up @@ -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];
}
Expand Down
32 changes: 32 additions & 0 deletions tests/MauiSherpa.Core.Tests/Services/DotnetUpServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<ILoggingService>());

var request = service.InstallSdkRequest();

request.AcceptsStandardInput.Should().Be(OperatingSystem.IsMacOS());
}

[Fact]
public void TrackedChannelInstallAcceptsStandardInputWhenPseudoTerminalIsAvailable()
{
var service = new DotnetUpService(Mock.Of<ILoggingService>());

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<ILoggingService>());

var request = service.CreateProcessRequest(["--info"]);

request.AcceptsStandardInput.Should().BeFalse();
}

[Fact]
public void SelectInstalledSdkTargetRejectsAmbiguousRoots()
{
Expand Down
Loading