Skip to content
Merged
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
13 changes: 8 additions & 5 deletions docs/iseberg.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,14 @@ PowerShell 7.4.0 through 7.4.5 cannot load the patched SDK's
`System.Management.Automation` 7.4.6.500 assembly reference and are rejected up front.
The child architecture does not need to match DT. Make `pwsh` available on PATH, or set
`DT_ISEBERG_PSHOME` to its absolute installation directory before starting DT.
DT checks its version and child-runtime compatibility. A short noninteractive
`pwsh -NoProfile` probe discovers and validates the installation. DT then starts
an owned `pwsh -NoLogo -NoProfile` child and imports the absolute path to its
`Devolutions.Iseberg.PowerShell.dll` binary module. The module creates a custom-host execution
runspace; it does not replace PowerShell's bootstrap console host.
DT checks its version and child-runtime compatibility without a separate probe
process: DT starts an owned `pwsh -NoLogo -NoProfile` child whose bootstrap first
prints one line describing `$PSHOME`, its version, .NET runtime and architecture,
then imports the absolute path to its `Devolutions.Iseberg.PowerShell.dll` binary
module. DT validates that description while the module loads, so an installation
too old to load the module still fails with the version it found. The module
creates a custom-host execution runspace; it does not replace PowerShell's
bootstrap console host.

Parser analysis, completion, execution, host input, debugging and portable
`$psISE` proxies run in that child. The NativeAOT parent uses typed, source-generated
Expand Down
4 changes: 3 additions & 1 deletion scripts/Install-IsebergTestPowerShell.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) {
New-Item -ItemType Directory -Path $engineDirectory -Force | Out-Null
$asset = if ($IsWindows) { "PowerShell-$version-$rid.zip" } else { "powershell-$version-$rid.tar.gz" }
$archive = Join-Path $root $asset
Invoke-WebRequest "https://github.com/PowerShell/PowerShell/releases/download/v$version/$asset" -OutFile $archive
# GitHub release downloads intermittently return 5xx pages; retry before failing the job.
Invoke-WebRequest "https://github.com/PowerShell/PowerShell/releases/download/v$version/$asset" -OutFile $archive `
-MaximumRetryCount 4 -RetryIntervalSec 15
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $hashes[$rid]) {
throw "PowerShell test runtime checksum mismatch: $archive"
}
Expand Down
92 changes: 0 additions & 92 deletions src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs

This file was deleted.

20 changes: 0 additions & 20 deletions src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs

This file was deleted.

3 changes: 0 additions & 3 deletions src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@
<PropertyGroup Condition="'$(EnablePowerShellIse)' == 'true'">
<DefineConstants>$(DefineConstants);POWERSHELL_ISE</DefineConstants>
</PropertyGroup>
<ItemGroup Condition="'$(EnablePowerShellIse)' != 'true'">
<Compile Remove="Connections\PowerShellInstallation.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ private async Task<TerminalTab> CreatePowerShellIseTabAsync(
#if POWERSHELL_ISE
if (_isClosed || _closeConfirmationPending || _applicationClosePending)
throw new InvalidOperationException("Cannot open Iseberg while DT is closing.");
await PowerShellIseRuntime.InitializeAsync().ConfigureAwait(true);
// The session's own pwsh reports and validates its installation during startup.
return await CreatePowerShellIseTabCoreAsync(profile, restored, regenerateIdentities).ConfigureAwait(true);
#else
await Task.CompletedTask;
Expand Down
10 changes: 10 additions & 0 deletions src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1801,6 +1801,16 @@ private void BeginTabDrag(TerminalTab tab, Control control, PointerPressedEventA
return;
}

// A press on a nested button (the close X) belongs to that button. This
// handler also sees handled events, so capturing here would steal the
// nested button's pointer capture and it would never raise Click.
if (e.Source is Visual source &&
Avalonia.VisualTree.VisualExtensions.FindAncestorOfType<Button>(source, includeSelf: true) is { } pressed &&
!ReferenceEquals(pressed, control))
{
return;
}

_draggedTab = tab;
_dragStart = e.GetPosition(TabStrip);
e.Pointer.Capture(control);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Iseberg.Core;

/// <summary>What the bridge's pwsh reports about itself before it loads the bridge module.</summary>
public sealed record PowerShellInstallation(string Home, string Version, string RuntimeVersion, string Architecture)
{
public const string HomeVariable = "DT_ISEBERG_PSHOME";
/// <summary>Prefix of the single stdout line written by <see cref="ReportScript"/>.</summary>
public const string ReportPrefix = "DT-ISEBERG-INSTALLATION:";
public static Version MinimumVersion => PowerShellCompatibility.MinimumVersion;

/// <summary>
/// Runs first in the bridge bootstrap, before the net8.0 module is imported, so a pwsh that cannot
/// load the module still describes itself and fails with an actionable message.
/// </summary>
public static string ReportScript { get; } =
"[Console]::Out.WriteLine('" + ReportPrefix + "' + ([pscustomobject]@{ " +
"Home = $PSHOME; " +
"Version = $PSVersionTable.PSVersion.ToString(); " +
"RuntimeVersion = [Environment]::Version.ToString(); " +
"Architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() " +
"} | ConvertTo-Json -Compress))";

public static string MissingMessage =>
$"Install PowerShell {PowerShellCompatibility.SupportedVersions} on PATH, or set {HomeVariable} to its installation directory.";

public static PowerShellInstallation Parse(string json)
{
try
{
return JsonSerializer.Deserialize(json, PowerShellInstallationJsonContext.Default.PowerShellInstallation)
?? throw new InvalidOperationException("PowerShell returned an empty installation description.");
}
catch (JsonException exception)
{
throw new InvalidOperationException("PowerShell returned an invalid installation description.", exception);
}
}

public void Validate()
{
if (!System.Version.TryParse(Version, out var version) ||
!PowerShellCompatibility.IsSupportedVersion(version))
throw new InvalidOperationException($"Iseberg requires PowerShell {PowerShellCompatibility.SupportedVersions}; found {Version}.");
if (!System.Version.TryParse(RuntimeVersion, out var runtime) ||
!PowerShellCompatibility.IsSupportedRuntime(version, runtime))
throw new InvalidOperationException($"PowerShell {version.Major}.{version.Minor} requires its bundled .NET {PowerShellCompatibility.RuntimeMajor(version)} runtime; found {RuntimeVersion}.");
if (!Enum.TryParse<System.Runtime.InteropServices.Architecture>(Architecture, out var architecture) ||
!Enum.IsDefined(architecture))
throw new InvalidOperationException($"PowerShell returned an unknown process architecture: {Architecture}.");
if (string.IsNullOrWhiteSpace(Home) || !Path.IsPathFullyQualified(Home) ||
!File.Exists(Path.Combine(Home, "System.Management.Automation.dll")) ||
!File.Exists(Path.Combine(Home, "pwsh.dll")))
throw new InvalidOperationException($"PowerShell installation is incomplete: {Home}.");
}
}

[JsonSerializable(typeof(PowerShellInstallation))]
internal sealed partial class PowerShellInstallationJsonContext : JsonSerializerContext;
63 changes: 51 additions & 12 deletions src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public sealed class PowerShellSession : IAsyncDisposable
private readonly object sync = new();
private readonly CancellationTokenSource lifetime = new();
private readonly StringBuilder diagnostics = new();
private readonly TaskCompletionSource<string?> installationReport = new(TaskCreationOptions.RunContinuationsAsynchronously);
private BridgeConnection? connection;
private NamedPipeServerStream? pipe;
private Process? child;
Expand Down Expand Up @@ -75,7 +76,9 @@ public async Task InitializeAsync()
{
if (!Path.IsPathFullyQualified(ModulePath) || !File.Exists(ModulePath))
throw new FileNotFoundException("The DT-owned Iseberg PowerShell bridge module is missing.", ModulePath);
var executable = ExecutablePath ?? PowerShellProcessDiscovery.FindExecutable();
string executable;
try { executable = ExecutablePath ?? PowerShellProcessDiscovery.FindExecutable(); }
catch (FileNotFoundException exception) { throw new InvalidOperationException(PowerShellInstallation.MissingMessage, exception); }
var endpoint = "dt-iseberg-" + Guid.NewGuid().ToString("N");
var authentication = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var challenge = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
Expand All @@ -87,19 +90,22 @@ public async Task InitializeAsync()
RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true
};
using var parent = Process.GetCurrentProcess();
var bootstrap = "Import-Module -Name '" + ModulePath.Replace("'", "''") +
var bootstrap = PowerShellInstallation.ReportScript + "; Import-Module -Name '" + ModulePath.Replace("'", "''") +
"' -ErrorAction Stop; Start-IsebergBridge -PipeName '" + endpoint +
"' -ParentProcessId " + Environment.ProcessId +
" -ParentStartTimeUtcTicks " + parent.StartTime.ToUniversalTime().Ticks;
foreach (var argument in new[] { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", bootstrap })
start.ArgumentList.Add(argument);
start.Environment[IseBridgeProtocol.AuthenticationEnvironment] = authentication;
child = Process.Start(start) ?? throw new InvalidOperationException("The PowerShell bridge did not start.");
try { child = Process.Start(start) ?? throw new InvalidOperationException("The PowerShell bridge did not start."); }
catch (System.ComponentModel.Win32Exception exception) { throw new InvalidOperationException(PowerShellInstallation.MissingMessage, exception); }
// Native programs inherit this stdin: give them end-of-input rather than DT's (or a hidden) console.
child.StandardInput.Close();
outputDrain = DrainAsync(child.StandardOutput);
outputDrain = DrainOutputAsync(child.StandardOutput);
errorDrain = DrainAsync(child.StandardError);
exitObservation = ObserveExitAsync(child);
// The bootstrap describes pwsh before importing the module, so this overlaps the module load.
(await ReadInstallationAsync().ConfigureAwait(false)).Validate();
await pipe.WaitForConnectionAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(30), lifetime.Token).ConfigureAwait(false);
connection = new(pipe) { Request = HandleRequestAsync, Event = HandleEvent };
connection.Disconnected += OnDisconnected;
Expand Down Expand Up @@ -399,24 +405,57 @@ private async Task<JsonElement> HandleRequestAsync(string operation, JsonElement
}
}

private async Task<PowerShellInstallation> ReadInstallationAsync()
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
string? report;
try { report = await installationReport.Task.WaitAsync(timeout.Token).ConfigureAwait(false); }
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
{
throw new InvalidOperationException("PowerShell did not respond to the installation check within 15 seconds.");
}
// Without a report, pwsh failed before it could describe itself.
return report is null
? throw new InvalidOperationException($"PowerShell installation check failed: {StartupDiagnostics()}")
: PowerShellInstallation.Parse(report);
}
private async Task DrainOutputAsync(StreamReader reader)
{
try
{
while (await reader.ReadLineAsync(lifetime.Token).ConfigureAwait(false) is { } line)
{
// The bootstrap's installation report is the only stdout line with meaning.
if (!installationReport.Task.IsCompleted &&
line.StartsWith(PowerShellInstallation.ReportPrefix, StringComparison.Ordinal))
installationReport.TrySetResult(line[PowerShellInstallation.ReportPrefix.Length..]);
else
AppendDiagnostics(line + Environment.NewLine);
}
}
catch (Exception exception) when (exception is IOException or OperationCanceledException or ObjectDisposedException) { }
finally { installationReport.TrySetResult(null); }
}
private async Task DrainAsync(StreamReader reader)
{
try
{
var buffer = new char[4096];
int read;
while ((read = await reader.ReadAsync(buffer, lifetime.Token).ConfigureAwait(false)) != 0)
{
// Raw stdout/stderr is diagnostics, never a second script-output transport.
lock (sync)
{
if (diagnostics.Length + read > 16384) diagnostics.Remove(0, Math.Min(diagnostics.Length, read));
diagnostics.Append(buffer, 0, read);
}
}
AppendDiagnostics(buffer.AsSpan(0, read));
}
catch (Exception exception) when (exception is IOException or OperationCanceledException or ObjectDisposedException) { }
}
// Raw stdout/stderr is diagnostics, never a second script-output transport.
private void AppendDiagnostics(ReadOnlySpan<char> text)
{
lock (sync)
{
if (diagnostics.Length + text.Length > 16384) diagnostics.Remove(0, Math.Min(diagnostics.Length, text.Length));
diagnostics.Append(text);
}
}
private async Task ObserveExitAsync(Process process)
{
await process.WaitForExitAsync().ConfigureAwait(false);
Expand Down
Loading
Loading