diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml
index 1a1d173..a9273e5 100644
--- a/.github/workflows/build-terminal.yml
+++ b/.github/workflows/build-terminal.yml
@@ -53,6 +53,7 @@ jobs:
msix_version: ${{ steps.resolve.outputs.msix_version }}
dry_run: ${{ steps.resolve.outputs.dry_run }}
sign_dry_run: ${{ steps.resolve.outputs.sign_dry_run }}
+ sign_packages: ${{ steps.resolve.outputs.sign_packages }}
publish_environment: ${{ steps.resolve.outputs.publish_environment }}
steps:
- name: Checkout
@@ -116,12 +117,15 @@ jobs:
}
$msixVersion = "$releaseVersion.0"
+ $signPackages = if (($env:GITHUB_EVENT_NAME -eq "workflow_dispatch" -or $env:GITHUB_REF_TYPE -eq "tag") -and
+ ($dryRun -eq "false" -or $signDryRun -eq "true")) { "true" } else { "false" }
@(
"release_tag=$tag"
"release_version=$releaseVersion"
"msix_version=$msixVersion"
"dry_run=$dryRun"
"sign_dry_run=$signDryRun"
+ "sign_packages=$signPackages"
"publish_environment=$publishEnvironment"
) | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
@@ -162,6 +166,12 @@ jobs:
- name: Test macOS legal notice layout
run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1
+ - name: Test ConPTY publish layouts
+ run: pwsh -NoLogo -NoProfile -File scripts/Test-ConPtyPublishLayout.ps1
+
+ - name: Test Windows NuGet signature guards
+ run: pwsh -NoLogo -NoProfile -File src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1
+
nuget-pack:
name: Pack Devolutions.Terminal.Control NuGet package
runs-on: windows-latest
@@ -319,6 +329,9 @@ jobs:
- name: Test macOS legal notice layout
run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1
+ - name: Test standalone macOS NuGet signing
+ run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsNuGetSigning.ps1
+
macos-native-aot:
name: macOS NativeAOT ${{ matrix.rid }}
runs-on: macos-26
@@ -382,6 +395,36 @@ jobs:
- name: Validate package without launching UI
run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsPackage.ps1 ${{ matrix.rid }} artifacts/macos-packages/*.zip
+ - name: Test release signing on the actual app bundle
+ # Exercise the release signer on both architectures in ordinary CI,
+ # without Developer ID credentials or notarization.
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $PSNativeCommandUseErrorActionPreference = $true
+ $testDirectory = "artifacts/macos-signing-validation/${{ matrix.rid }}"
+ New-Item -ItemType Directory -Force -Path $testDirectory | Out-Null
+ $testApp = Join-Path $testDirectory 'Devolutions Terminal.app'
+ # Leave the uploaded app, zip, and their checksum manifest unchanged.
+ & /bin/cp -a "artifacts/macos-packages/Devolutions Terminal.app" $testApp
+ ./scripts/Sign-MacOsPackage.ps1 $testApp -
+
+ - name: Stage standalone macOS NuGet payload
+ shell: pwsh
+ run: >
+ ./scripts/Stage-MacOsNuGetPayload.ps1
+ -AppPath "artifacts/macos-signing-validation/${{ matrix.rid }}/Devolutions Terminal.app"
+ -OutputDirectory "artifacts/macos-nuget/${{ matrix.rid }}"
+ -Rid "${{ matrix.rid }}"
+ -Identity -
+
+ - name: Upload unsigned macOS NuGet payload
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevolutionsTerminal-${{ matrix.rid }}-nuget-payload
+ path: artifacts/macos-nuget/${{ matrix.rid }}
+ if-no-files-found: error
+
- name: Run native non-UI gates
# NativeAOT osx-x64 binaries are cross-compiled on the arm64 runner and
# cannot be executed here (no Rosetta on Actions macOS images); only the
@@ -518,6 +561,25 @@ jobs:
- name: Validate final package
run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsPackage.ps1 ${{ matrix.rid }} artifacts/macos-signed-packages/*.zip
+ - name: Stage release macOS NuGet payload
+ shell: pwsh
+ env:
+ SHOULD_SIGN: ${{ steps.signing-mode.outputs.should_sign }}
+ SIGNING_IDENTITY: ${{ steps.import_certificate.outputs.identity }}
+ run: |
+ $identity = if ($env:SHOULD_SIGN -eq 'true') { $env:SIGNING_IDENTITY } else { '-' }
+ ./scripts/Stage-MacOsNuGetPayload.ps1 `
+ -AppPath "artifacts/macos-signed-packages/Devolutions Terminal.app" `
+ -OutputDirectory "artifacts/macos-nuget/${{ matrix.rid }}" `
+ -Rid "${{ matrix.rid }}" -Identity $identity
+
+ - name: Upload release macOS NuGet payload
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevolutionsTerminal-${{ matrix.rid }}-signed-nuget-payload
+ path: artifacts/macos-nuget/${{ matrix.rid }}
+ if-no-files-found: error
+
- name: Upload final macOS package artifacts
uses: actions/upload-artifact@v4
with:
@@ -899,10 +961,24 @@ jobs:
nuget:
name: NuGet distribution package
+ # macos-sign is intentionally skipped outside releases; do not let that
+ # suppress ordinary NuGet CI, or let a failed signer fall back to raw code.
+ if: >-
+ ${{ always() && !cancelled() &&
+ needs.release-metadata.result == 'success' &&
+ needs.msi.result == 'success' &&
+ needs.linux-packages.result == 'success' &&
+ needs.macos-native-aot.result == 'success' &&
+ (needs.macos-sign.result == 'success' ||
+ (needs.macos-sign.result == 'skipped' &&
+ github.event_name != 'workflow_dispatch' &&
+ !startsWith(github.ref, 'refs/tags/'))) }}
needs:
- native-aot
+ - msi
- linux-packages
- macos-native-aot
+ - macos-sign
- release-metadata
runs-on: windows-latest
steps:
@@ -914,16 +990,22 @@ jobs:
with:
dotnet-version: 10.0.x
- - name: Download Windows x64 publish
+ - name: Set up Windows signature verification
+ if: needs.release-metadata.outputs.sign_packages == 'true'
+ uses: microsoft/setup-WinAppCli@v0.1
+ with:
+ version: v0.6.1
+
+ - name: Download Windows x64 NuGet payload
uses: actions/download-artifact@v4
with:
- name: DevolutionsTerminal-win-x64
+ name: DevolutionsTerminal-win-x64-nuget-payload
path: artifacts/nuget/layout/win-x64
- - name: Download Windows arm64 publish
+ - name: Download Windows arm64 NuGet payload
uses: actions/download-artifact@v4
with:
- name: DevolutionsTerminal-win-arm64
+ name: DevolutionsTerminal-win-arm64-nuget-payload
path: artifacts/nuget/layout/win-arm64
- name: Download Linux x64 publish
@@ -938,31 +1020,53 @@ jobs:
name: DevolutionsTerminal-linux-arm64
path: artifacts/nuget/layout/linux-arm64
- - name: Download macOS x64 publish
+ - name: Download macOS x64 NuGet payload
uses: actions/download-artifact@v4
with:
- name: DevolutionsTerminal-osx-x64
+ name: DevolutionsTerminal-osx-x64-${{ needs.macos-sign.result == 'success' && 'signed-nuget-payload' || 'nuget-payload' }}
path: artifacts/nuget/layout/osx-x64
- - name: Download macOS arm64 publish
+ - name: Download macOS arm64 NuGet payload
uses: actions/download-artifact@v4
with:
- name: DevolutionsTerminal-osx-arm64
+ name: DevolutionsTerminal-osx-arm64-${{ needs.macos-sign.result == 'success' && 'signed-nuget-payload' || 'nuget-payload' }}
path: artifacts/nuget/layout/osx-arm64
- name: Build NuGet distribution packages
shell: pwsh
- run: >
- ./src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1
- -SkipPublish
- -Version "${{ needs.release-metadata.outputs.release_version }}"
+ env:
+ REQUIRE_SIGNATURE: ${{ needs.release-metadata.outputs.sign_packages }}
+ WINDOWS_BINARIES_SIGNED: ${{ needs.msi.outputs.binaries_signed }}
+ EXPECTED_PUBLISHER: ${{ needs.msi.outputs.publisher }}
+ run: |
+ $arguments = @{
+ SkipPublish = $true
+ Version = '${{ needs.release-metadata.outputs.release_version }}'
+ }
+ if ($env:REQUIRE_SIGNATURE -eq 'true') {
+ if ($env:WINDOWS_BINARIES_SIGNED -ne 'true') {
+ throw 'Signed NuGet release requires signed Windows payloads.'
+ }
+ $arguments.RequireWindowsSignature = $true
+ $arguments.ExpectedPublisher = $env:EXPECTED_PUBLISHER
+ }
+ ./src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 @arguments
- name: Smoke test NuGet package import
shell: pwsh
- run: >
- ./src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1
- -PackageDirectory ./artifacts/nuget/packages
- -Version "${{ needs.release-metadata.outputs.release_version }}"
+ env:
+ REQUIRE_SIGNATURE: ${{ needs.release-metadata.outputs.sign_packages }}
+ EXPECTED_PUBLISHER: ${{ needs.msi.outputs.publisher }}
+ run: |
+ $arguments = @{
+ PackageDirectory = './artifacts/nuget/packages'
+ Version = '${{ needs.release-metadata.outputs.release_version }}'
+ }
+ if ($env:REQUIRE_SIGNATURE -eq 'true') {
+ $arguments.RequireWindowsSignature = $true
+ $arguments.ExpectedPublisher = $env:EXPECTED_PUBLISHER
+ }
+ ./src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 @arguments
- name: Upload NuGet distribution packages
uses: actions/upload-artifact@v4
@@ -971,8 +1075,62 @@ jobs:
path: artifacts/nuget/packages/*.nupkg
if-no-files-found: error
+ - name: Upload macOS x64 NuGet package for native verification
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevolutionsTerminal-osx-x64-nuget-package
+ path: artifacts/nuget/packages/Devolutions.Terminal.App.osx-x64.*.nupkg
+ if-no-files-found: error
+
+ - name: Upload macOS arm64 NuGet package for native verification
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevolutionsTerminal-osx-arm64-nuget-package
+ path: artifacts/nuget/packages/Devolutions.Terminal.App.osx-arm64.*.nupkg
+ if-no-files-found: error
+
+ nuget-macos:
+ name: macOS NuGet signatures ${{ matrix.rid }}
+ if: ${{ !cancelled() && needs.nuget.result == 'success' && needs.release-metadata.result == 'success' }}
+ runs-on: macos-26
+ needs:
+ - nuget
+ - release-metadata
+ strategy:
+ fail-fast: false
+ matrix:
+ rid: [osx-arm64, osx-x64]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Download packaged NuGet payload
+ uses: actions/download-artifact@v4
+ with:
+ name: DevolutionsTerminal-${{ matrix.rid }}-nuget-package
+ path: artifacts/nuget-validation
+
+ - name: Verify signatures in the actual NuGet package
+ shell: pwsh
+ env:
+ REQUIRE_SIGNATURE: ${{ needs.release-metadata.outputs.sign_packages }}
+ run: |
+ $package = Get-ChildItem artifacts/nuget-validation -Filter '*.nupkg' -File
+ if (@($package).Count -ne 1) { throw 'Expected exactly one macOS runtime package.' }
+ [IO.Compression.ZipFile]::ExtractToDirectory(
+ $package.FullName, [IO.Path]::GetFullPath('artifacts/nuget-validation/expanded'))
+ $arguments = @{
+ PayloadDirectory = 'artifacts/nuget-validation/expanded/runtimes/${{ matrix.rid }}/native/payload'
+ Rid = '${{ matrix.rid }}'
+ }
+ if ($env:REQUIRE_SIGNATURE -eq 'true') { $arguments.RequireDeveloperId = $true }
+ ./scripts/Test-MacOsNuGetPayload.ps1 @arguments
+
msi:
name: MSI packages
+ outputs:
+ binaries_signed: ${{ steps.signing-mode.outputs.should_sign }}
+ publisher: ${{ steps.signing-mode.outputs.publisher }}
needs:
- native-aot
- release-metadata
@@ -1017,6 +1175,7 @@ jobs:
TRUSTED_SIGNING_ENDPOINT: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }}
TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }}
TRUSTED_SIGNING_PROFILE_NAME: ${{ secrets.TRUSTED_SIGNING_PROFILE_NAME }}
+ REQUESTED_PUBLISHER: ${{ vars.TRUSTED_SIGNING_PUBLISHER }}
run: |
# Ordinary (non-release) CI runs never attempt binary signing: they are not gated on
# signing secrets being configured, so they must not fail when those secrets are absent.
@@ -1045,15 +1204,15 @@ jobs:
}
if ($missing.Count -gt 0) {
- if ($dryRun) {
- "should_sign=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
- Write-Host "::notice::Skipping dry-run binary signing because these secrets are unavailable: $($missing -join ', ')"
- exit 0
- }
-
throw "Missing Azure Artifact Signing secrets: $($missing -join ', ')"
}
+ $publisher = $env:REQUESTED_PUBLISHER
+ if ([string]::IsNullOrWhiteSpace($publisher)) {
+ $publisher = "CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA"
+ }
+ if ($publisher -match '[\r\n]') { throw 'TRUSTED_SIGNING_PUBLISHER must be a single-line certificate subject.' }
+ "publisher=$publisher" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
"should_sign=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
- name: Install Windows psign-tool
@@ -1123,6 +1282,29 @@ jobs:
-ArtifactSigningAccessToken $accessToken `
-TimestampServer $timestampServer
+ - name: Verify signed Windows NuGet payloads
+ if: steps.signing-mode.outputs.should_sign == 'true'
+ shell: pwsh
+ env:
+ EXPECTED_PUBLISHER: ${{ steps.signing-mode.outputs.publisher }}
+ run: |
+ ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-x64 -ExpectedPublisher $env:EXPECTED_PUBLISHER
+ ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-arm64 -ExpectedPublisher $env:EXPECTED_PUBLISHER
+
+ - name: Upload Windows x64 NuGet payload
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevolutionsTerminal-win-x64-nuget-payload
+ path: artifacts/msi/layout/win-x64
+ if-no-files-found: error
+
+ - name: Upload Windows arm64 NuGet payload
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevolutionsTerminal-win-arm64-nuget-payload
+ path: artifacts/msi/layout/win-arm64
+ if-no-files-found: error
+
- name: Build MSI packages
shell: pwsh
run: >
@@ -1171,6 +1353,7 @@ jobs:
- msix
- msi
- nuget
+ - nuget-macos
- nuget-pack
- release-metadata
runs-on: ubuntu-latest
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 0000000..2a1f39c
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/docs/macos.md b/docs/macos.md
index 54b98cf..1da94eb 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -65,6 +65,13 @@ bundle and writes a zip plus SHA-256 manifest.
Published `THIRD-PARTY-NOTICES*.txt` files (including transitive dependency notices) are preserved in `Contents/Resources` alongside `LICENSE`, not in the code-only `Contents/MacOS` directory.
`Test-MacOsLegalNotices.ps1` checks this layout without requiring Apple signing credentials.
+Unix publishes exclude Windows-only ConPTY `OpenConsole.exe` hosts from the final publish list, including files supplied by RID-less project references.
+Package validation and signing check every file under `Contents/MacOS`, not just top-level files.
+Ordinary CI runs the release signing script with an ad-hoc identity on both actual NativeAOT app bundles, so nested-code failures are caught before a credentialed release.
+`Stage-MacOsNuGetPayload.ps1` creates the existing flat NuGet native layout from that app and signs the copied code as standalone executables/libraries.
+Signed releases use the same Developer ID identity, Hardened Runtime, application entitlements, and secure timestamps; unsigned CI uses an ad-hoc identity.
+Both actual macOS NuGet packages are extracted and checked by `Test-MacOsNuGetPayload.ps1` on macOS before release publication.
+The NuGet layout is not a notarized app bundle and does not inherit its stapled ticket.
```bash
open "artifacts/packages/Devolutions Terminal.app"
diff --git a/docs/release.md b/docs/release.md
index 708def0..0a549f8 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -22,6 +22,8 @@ the ARM64 host required by x64 processes running under emulation. Because
output root once a RID is applied, the hosts are emitted in both layouts; a RID
does not reliably flow to referenced projects, so the placement cannot depend on
it.
+Unix RID-specific publishes remove the Windows-only `OpenConsole.exe` hosts from the final publish list; RID-less builds and Windows publishes retain both host layouts.
+ConPTY uses asynchronous host-side pipes and registered process-exit waits, with synchronous pipe endpoints for the console host, so idle sessions and blocked input do not exhaust the worker pool.
Linux and macOS local sessions use the
bundled `forkpty` relay. The Avalonia shell, settings, renderer, and terminal
engines are shared.
@@ -301,6 +303,13 @@ that are not yet available. Splitting the NativeAOT payloads keeps each package
below NuGet.org's 250 MB package-size limit while preserving the existing
`PackageReference` and MSBuild import behavior.
+For signed releases and signed dry runs, the NuGet job waits for platform signing and consumes the verified Windows MSI native layouts and standalone Developer ID-signed macOS payloads.
+It never falls back to the original unsigned publish artifacts when signing fails or required credentials are missing.
+The actual packaged Windows payloads are checked after consumer restore/build; both macOS `.nupkg` payloads are extracted and signature-verified on a native macOS runner before publication.
+The flat macOS NuGet contract is preserved by re-signing copied native code outside the signed `.app`, with Hardened Runtime, the application entitlements, and secure timestamps.
+This does not transfer the app bundle's notarization ticket; ZIP and DMG remain the notarized app distributions.
+Runtime packages exclude native debug symbols, while ordinary CI and unsigned dry runs continue without protected signing credentials.
+
Configure the `publish-test` and `publish-prod` environments as trusted
publishers for the package IDs on NuGet.org, and bootstrap the RID and pointer
packages before the first publication. Dry runs do not request a NuGet API key
diff --git a/scripts/Sign-MacOsPackage.ps1 b/scripts/Sign-MacOsPackage.ps1
index 01103dd..dfd95db 100644
--- a/scripts/Sign-MacOsPackage.ps1
+++ b/scripts/Sign-MacOsPackage.ps1
@@ -70,11 +70,13 @@ $mainExecutable = Join-Path $contents 'MacOS' $mainExecutableName
$macosDirectory = Join-Path $contents 'MacOS'
$invalidMacOsFiles = @(
- Get-ChildItem -LiteralPath $macosDirectory -File |
+ Get-ChildItem -LiteralPath $macosDirectory -Recurse -Force -File |
Where-Object { -not (Test-MachO -Path $_.FullName) }
)
if ($invalidMacOsFiles.Count -gt 0) {
- $invalidNames = ($invalidMacOsFiles.Name | Sort-Object) -join ', '
+ $invalidNames = ($invalidMacOsFiles | ForEach-Object {
+ [IO.Path]::GetRelativePath($macosDirectory, $_.FullName)
+ } | Sort-Object) -join ', '
throw "Contents/MacOS may contain only Mach-O code; move or remove these data files before signing: $invalidNames"
}
diff --git a/scripts/Stage-MacOsNuGetPayload.ps1 b/scripts/Stage-MacOsNuGetPayload.ps1
new file mode 100644
index 0000000..363318c
--- /dev/null
+++ b/scripts/Stage-MacOsNuGetPayload.ps1
@@ -0,0 +1,65 @@
+#!/usr/bin/env pwsh
+#Requires -Version 7
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string]$AppPath,
+ [Parameter(Mandatory)][string]$OutputDirectory,
+ [Parameter(Mandatory)][ValidateSet('osx-arm64', 'osx-x64')][string]$Rid,
+ [Parameter(Mandatory)][string]$Identity
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force
+Assert-Darwin
+Assert-Command -Name 'codesign', 'cp', 'chmod'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$metadata = Import-MacOsPackageEnv -Path (Join-Path $repoRoot 'macos/package.env')
+$entitlements = if ($env:MACOS_ENTITLEMENTS) { $env:MACOS_ENTITLEMENTS } else {
+ Join-Path $repoRoot 'macos/entitlements.plist'
+}
+$requirements = @{ RequireHardenedRuntime = $true }
+if ($Identity -ne '-') {
+ $requirements.TeamIdentifier = $metadata.APPLE_TEAM_ID
+ $requirements.RequireTimestamp = $true
+ Assert-MacOsCodeSignature -Path $AppPath @requirements
+}
+Invoke-Native -FilePath codesign -ArgumentList '--verify', '--deep', '--strict', $AppPath
+if (Test-Path -LiteralPath $OutputDirectory) {
+ throw "NuGet payload output directory already exists: $OutputDirectory"
+}
+New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
+
+# NuGet's public contract is a flat native layout, not an .app. Bundle-level
+# Info.plist/resource seals cannot follow an executable out of its bundle.
+$binaries = @(
+ $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME,
+ $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib'
+)
+foreach ($name in $binaries) {
+ $source = Join-Path $AppPath "Contents/MacOS/$name"
+ if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
+ throw "App bundle is missing $name."
+ }
+ $destination = Join-Path $OutputDirectory $name
+ Invoke-Native -FilePath cp -ArgumentList '-p', $source, $destination
+ $arguments = @('--force', '--options', 'runtime')
+ if ($Identity -ne '-') { $arguments += '--timestamp' }
+ if ($name -in @($metadata.EXECUTABLE_NAME, $metadata.CLI_NAME)) {
+ $arguments += @('--entitlements', $entitlements)
+ }
+ $arguments += @('--sign', $Identity, $destination)
+ Invoke-Native -FilePath codesign -ArgumentList $arguments
+ Assert-MacOsCodeSignature -Path $destination @requirements
+}
+$resources = Join-Path $AppPath 'Contents/Resources'
+Copy-Item -LiteralPath (Join-Path $resources 'LICENSE') -Destination $OutputDirectory
+Get-ChildItem -LiteralPath $resources -File -Filter 'THIRD-PARTY-NOTICES*.txt' |
+ Copy-Item -Destination $OutputDirectory
+Invoke-Native -FilePath chmod -ArgumentList @(
+ '0755', (Join-Path $OutputDirectory $metadata.EXECUTABLE_NAME),
+ (Join-Path $OutputDirectory $metadata.CLI_NAME), (Join-Path $OutputDirectory $metadata.PTY_HOST_NAME)
+)
+$testArguments = @{ PayloadDirectory = $OutputDirectory; Rid = $Rid }
+if ($Identity -ne '-') { $testArguments.RequireDeveloperId = $true }
+& (Join-Path $PSScriptRoot 'Test-MacOsNuGetPayload.ps1') @testArguments
diff --git a/scripts/Test-ConPtyPublishLayout.ps1 b/scripts/Test-ConPtyPublishLayout.ps1
new file mode 100644
index 0000000..d6bb46c
--- /dev/null
+++ b/scripts/Test-ConPtyPublishLayout.ps1
@@ -0,0 +1,47 @@
+#!/usr/bin/env pwsh
+#Requires -Version 7
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$targets = [Security.SecurityElement]::Escape((Join-Path $repoRoot 'Directory.Build.targets'))
+$work = Join-Path ([IO.Path]::GetTempPath()) "conpty-publish-test-$([guid]::NewGuid().ToString('N'))"
+
+try {
+ New-Item -ItemType Directory -Path $work | Out-Null
+ $project = Join-Path $work 'PublishLayout.proj'
+ @"
+
+
+
+
+
+
+
+"@ | Set-Content -LiteralPath $project -Encoding utf8
+
+ foreach ($rid in @('', 'win-x86', 'win-x64', 'win-arm64', 'osx-x64', 'osx-arm64', 'linux-x64', 'linux-arm64')) {
+ $output = & dotnet msbuild $project -t:ComputeFilesToPublish "-p:RuntimeIdentifier=$rid" `
+ -getItem:ResolvedFileToPublish -verbosity:quiet
+ if ($LASTEXITCODE -ne 0) {
+ throw "Publish layout evaluation failed for '$rid': $output"
+ }
+ $items = ($output -join "`n" | ConvertFrom-Json).Items.ResolvedFileToPublish
+ $hosts = @($items | Where-Object { "$($_.Filename)$($_.Extension)" -eq 'OpenConsole.exe' })
+ $expectedHosts = if ($rid -match '^(osx|linux)-') { 0 } else { 9 }
+ if ($hosts.Count -ne $expectedHosts -or $items.Count -ne $expectedHosts + 3) {
+ throw "Unexpected publish layout for '$rid': expected $expectedHosts Windows hosts and 3 retained files, got $($hosts.Count) hosts and $($items.Count) files."
+ }
+ foreach ($retained in @('THIRD-PARTY-NOTICES-CONPTY.txt', 'dt', 'libghostty-vt.dylib')) {
+ if (@($items | Where-Object { "$($_.Filename)$($_.Extension)" -ceq $retained }).Count -ne 1) {
+ throw "Publish layout for '$rid' lost $retained."
+ }
+ }
+ Write-Host "ConPtyPublishLayout ($rid): $expectedHosts Windows hosts; legal notice and native payload preserved."
+ }
+}
+finally {
+ Remove-Item -LiteralPath $work -Recurse -Force
+}
diff --git a/scripts/Test-MacOsCodeSigning.ps1 b/scripts/Test-MacOsCodeSigning.ps1
index 56717e0..37a4588 100644
--- a/scripts/Test-MacOsCodeSigning.ps1
+++ b/scripts/Test-MacOsCodeSigning.ps1
@@ -99,6 +99,29 @@ try {
$resources = Join-Path $contents 'Resources'
New-Item -ItemType Directory -Path $resources | Out-Null
Move-MacOsLegalNotices -MacOsDirectory $macosDir -ResourcesDirectory $resources
+ foreach ($relativePath in @(
+ 'x64/OpenConsole.exe',
+ 'runtimes/win-arm64/native/arm64/OpenConsole.exe'
+ )) {
+ $nestedCode = Join-Path $macosDir $relativePath
+ New-Item -ItemType Directory -Path (Split-Path -Parent $nestedCode) -Force | Out-Null
+ [IO.File]::WriteAllBytes($nestedCode, [byte[]](0x4D, 0x5A, 0, 0))
+ $nestedCodeRejected = $false
+ try {
+ & (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-'
+ }
+ catch {
+ if ($_.Exception.Message -notmatch 'Contents/MacOS may contain only Mach-O code.*OpenConsole\.exe') {
+ throw
+ }
+ $nestedCodeRejected = $true
+ }
+ if (-not $nestedCodeRejected) {
+ throw "Sign-MacOsPackage.ps1 accepted nested Windows code: $relativePath."
+ }
+ Remove-Item -LiteralPath $nestedCode -Force
+ }
+ Remove-Item -LiteralPath (Join-Path $macosDir 'x64'), (Join-Path $macosDir 'runtimes') -Recurse -Force
& (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-'
if ($LASTEXITCODE -ne 0) {
throw "Sign-MacOsPackage.ps1 failed with exit code $LASTEXITCODE."
diff --git a/scripts/Test-MacOsNuGetPayload.ps1 b/scripts/Test-MacOsNuGetPayload.ps1
new file mode 100644
index 0000000..5a1b283
--- /dev/null
+++ b/scripts/Test-MacOsNuGetPayload.ps1
@@ -0,0 +1,60 @@
+#!/usr/bin/env pwsh
+#Requires -Version 7
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string]$PayloadDirectory,
+ [Parameter(Mandatory)][ValidateSet('osx-arm64', 'osx-x64')][string]$Rid,
+ [switch]$RequireDeveloperId
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force
+Assert-Darwin
+Assert-Command -Name 'codesign', 'file', 'lipo'
+$metadata = Import-MacOsPackageEnv -Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'macos/package.env')
+$expectedArch = Get-MacOsExpectedArch -Rid $Rid
+$requirements = @{ RequireHardenedRuntime = $true }
+if ($RequireDeveloperId) {
+ $requirements.TeamIdentifier = $metadata.APPLE_TEAM_ID
+ $requirements.RequireTimestamp = $true
+}
+$binaries = @(
+ $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME,
+ $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib'
+)
+foreach ($name in $binaries) {
+ $path = Join-Path $PayloadDirectory $name
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "NuGet payload is missing $name." }
+ $kind = & file -b $path
+ if ($LASTEXITCODE -ne 0 -or $kind -notmatch 'Mach-O') { throw "$name is not Mach-O code." }
+ $architectures = & lipo -archs $path
+ if ($LASTEXITCODE -ne 0 -or $architectures -split '\s+' -notcontains $expectedArch) {
+ throw "$name does not support $Rid."
+ }
+ Assert-MacOsCodeSignature -Path $path @requirements
+ if ($name -in @($metadata.EXECUTABLE_NAME, $metadata.CLI_NAME)) {
+ $entitlementText = (& codesign --display --entitlements - --xml $path 2>$null) -join "`n"
+ if ($LASTEXITCODE -ne 0) { throw "Unable to inspect $name entitlements." }
+ [xml]$entitlements = $entitlementText
+ foreach ($key in @(
+ 'com.apple.security.cs.allow-jit',
+ 'com.apple.security.cs.allow-unsigned-executable-memory',
+ 'com.apple.security.cs.disable-library-validation'
+ )) {
+ $node = $entitlements.SelectSingleNode("/plist/dict/key[text()='$key']")
+ if ($null -eq $node -or $node.NextSibling.LocalName -ne 'true') {
+ throw "$name is missing required runtime entitlement $key."
+ }
+ }
+ }
+}
+foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) {
+ if (-not (Test-Path -LiteralPath (Join-Path $PayloadDirectory $name) -PathType Leaf)) {
+ throw "NuGet payload is missing $name."
+ }
+}
+$unexpected = @(Get-ChildItem -LiteralPath $PayloadDirectory -Recurse -Force |
+ Where-Object { $_.PSIsContainer -or $_.Name -match '\.(pdb|dbg|dSYM|exe|runtimeconfig\.json)$' })
+if ($unexpected.Count -gt 0) { throw "Unexpected files/directories in the standalone macOS NuGet payload: $($unexpected.Name -join ', ')" }
+Write-Host "macOS $Rid standalone NuGet payload signatures and layout passed."
diff --git a/scripts/Test-MacOsNuGetSigning.ps1 b/scripts/Test-MacOsNuGetSigning.ps1
new file mode 100644
index 0000000..b96c867
--- /dev/null
+++ b/scripts/Test-MacOsNuGetSigning.ps1
@@ -0,0 +1,65 @@
+#!/usr/bin/env pwsh
+#Requires -Version 7
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force
+Assert-Darwin
+Assert-Command -Name 'codesign', 'cp', 'clang'
+$metadata = Import-MacOsPackageEnv -Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'macos/package.env')
+$rid = if ((& uname -m) -eq 'arm64') { 'osx-arm64' } else { 'osx-x64' }
+$work = Join-Path ([IO.Path]::GetTempPath()) "macos-nuget-signing-$([guid]::NewGuid().ToString('N'))"
+$app = Join-Path $work 'Fixture.app'
+$code = Join-Path $app 'Contents/MacOS'
+$resources = Join-Path $app 'Contents/Resources'
+try {
+ New-Item -ItemType Directory -Path $code, $resources -Force | Out-Null
+ $fixtureSource = Join-Path $work 'fixture.c'
+ $fixtureBinary = Join-Path $work 'fixture'
+ Set-Content $fixtureSource 'int main(void) { return 0; }' -NoNewline
+ Invoke-Native -FilePath clang -ArgumentList '-arch', (Get-MacOsExpectedArch -Rid $rid), $fixtureSource, '-o', $fixtureBinary
+ foreach ($name in @(
+ $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME,
+ $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib'
+ )) {
+ Invoke-Native -FilePath cp -ArgumentList $fixtureBinary, (Join-Path $code $name)
+ }
+ @"
+
+
+CFBundleExecutable$($metadata.EXECUTABLE_NAME)
+CFBundleIdentifier$($metadata.APP_ID)
+CFBundlePackageTypeAPPL
+
+"@ | Set-Content (Join-Path $app 'Contents/Info.plist') -Encoding utf8
+ foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) {
+ Set-Content (Join-Path $resources $name) "fixture license $name" -NoNewline
+ }
+ & (Join-Path $PSScriptRoot 'Sign-MacOsPackage.ps1') $app '-'
+ $sourceHash = (Get-FileHash (Join-Path $code $metadata.EXECUTABLE_NAME)).Hash
+ $payload = Join-Path $work 'payload'
+ & (Join-Path $PSScriptRoot 'Stage-MacOsNuGetPayload.ps1') -AppPath $app -OutputDirectory $payload -Rid $rid -Identity '-'
+ if ((Get-FileHash (Join-Path $code $metadata.EXECUTABLE_NAME)).Hash -cne $sourceHash) {
+ throw 'Standalone signing mutated the source app.'
+ }
+ foreach ($notice in Get-ChildItem $resources -File) {
+ if ((Get-FileHash (Join-Path $payload $notice.Name)).Hash -cne (Get-FileHash $notice.FullName).Hash) {
+ throw "Standalone payload changed legal notice $($notice.Name)."
+ }
+ }
+ $rejected = $false
+ try {
+ & (Join-Path $PSScriptRoot 'Test-MacOsNuGetPayload.ps1') -PayloadDirectory $payload -Rid $rid -RequireDeveloperId
+ }
+ catch {
+ if ($_.Exception.Message -notmatch 'not signed by Apple team') { throw }
+ $rejected = $true
+ }
+ if (-not $rejected) { throw 'A signed release accepted an ad-hoc NuGet payload.' }
+ Write-Host 'StandaloneMacOsNuGetSigning_PreservesSourceAndLicensesAndRejectsAdHocRelease passed.'
+}
+finally {
+ Remove-Item -LiteralPath $work -Recurse -Force
+}
diff --git a/scripts/Test-MacOsPackage.ps1 b/scripts/Test-MacOsPackage.ps1
index eb73006..629caf7 100644
--- a/scripts/Test-MacOsPackage.ps1
+++ b/scripts/Test-MacOsPackage.ps1
@@ -159,6 +159,16 @@ function Test-MacOsAppBundle {
if ($dsyms) {
throw "$label contains dSYM bundles."
}
+ $invalidCodeFiles = @(
+ Get-ChildItem -LiteralPath $macosDir -Recurse -Force -File |
+ Where-Object { (& file -b $_.FullName) -notmatch 'Mach-O' }
+ )
+ if ($invalidCodeFiles.Count -gt 0) {
+ $invalidNames = ($invalidCodeFiles | ForEach-Object {
+ [IO.Path]::GetRelativePath($macosDir, $_.FullName)
+ } | Sort-Object) -join ', '
+ throw "$label Contents/MacOS contains non-Mach-O files: $invalidNames"
+ }
$runtimeConfig = Get-ChildItem -LiteralPath $macosDir -File -Filter '*.runtimeconfig.json'
if ($runtimeConfig) {
throw "$label contains NativeAOT runtime configuration in Contents/MacOS."
diff --git a/scripts/Test-MacOsPackagingMetadata.ps1 b/scripts/Test-MacOsPackagingMetadata.ps1
index c0a8738..228169d 100644
--- a/scripts/Test-MacOsPackagingMetadata.ps1
+++ b/scripts/Test-MacOsPackagingMetadata.ps1
@@ -26,6 +26,9 @@ $scripts = @(
'Test-MacOsRuntime.ps1',
'Test-MacOsCodeSigning.ps1',
'Test-MacOsLegalNotices.ps1',
+ 'Stage-MacOsNuGetPayload.ps1',
+ 'Test-MacOsNuGetPayload.ps1',
+ 'Test-MacOsNuGetSigning.ps1',
'Sign-MacOsPackage.ps1',
'Build-MacOsDmg.ps1',
'Notarize-MacOsPackage.ps1',
diff --git a/src/Devolutions.Terminal.Connection/ConPtyConnection.cs b/src/Devolutions.Terminal.Connection/ConPtyConnection.cs
index d4d98da..e3ce03b 100644
--- a/src/Devolutions.Terminal.Connection/ConPtyConnection.cs
+++ b/src/Devolutions.Terminal.Connection/ConPtyConnection.cs
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Diagnostics;
+using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
@@ -199,14 +200,12 @@ public async ValueTask DisposeAsync()
private void StartCore(TerminalLaunchOptions options, CancellationToken cancellationToken)
{
- SafeFileHandle? inputRead = null;
- SafeFileHandle? inputWrite = null;
- SafeFileHandle? outputRead = null;
- SafeFileHandle? outputWrite = null;
+ NamedPipeClientStream? inputRead = null;
+ NamedPipeClientStream? outputWrite = null;
SafePseudoConsoleHandle? pseudoConsole = null;
SafeKernelObjectHandle? process = null;
- FileStream? inputStream = null;
- FileStream? outputStream = null;
+ NamedPipeServerStream? inputStream = null;
+ NamedPipeServerStream? outputStream = null;
CancellationTokenSource? lifetime = null;
lock (_stateLock)
@@ -217,15 +216,16 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella
try
{
- CreatePipe(out inputRead, out inputWrite);
- CreatePipe(out outputRead, out outputWrite);
+ CreatePipe(PipeDirection.Out, out inputStream, out inputRead);
+ CreatePipe(PipeDirection.In, out outputStream, out outputWrite);
var size = new Kernel32.Coord
{
X = (short)options.Columns,
Y = (short)options.Rows,
};
- var hr = ConPty.CreatePseudoConsole(size, inputRead, outputWrite, 0, out var pseudoConsoleValue);
+ var hr = ConPty.CreatePseudoConsole(
+ size, inputRead.SafePipeHandle, outputWrite.SafePipeHandle, 0, out var pseudoConsoleValue);
if (hr != 0)
{
Marshal.ThrowExceptionForHR(hr);
@@ -235,10 +235,6 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella
var processResult = StartProcess(options, pseudoConsole);
process = processResult.Handle;
- inputStream = new FileStream(inputWrite, FileAccess.Write, 4096, isAsync: false);
- inputWrite = null;
- outputStream = new FileStream(outputRead, FileAccess.Read, 4096, isAsync: false);
- outputRead = null;
lifetime = new CancellationTokenSource();
var generation = ++_generation;
@@ -296,8 +292,8 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella
outputStream = null;
lifetime = null;
- session.ReadTask = Task.Run(() => ReadLoop(session));
- session.WaitTask = Task.Run(() => WaitLoop(session));
+ session.ReadTask = ReadLoopAsync(session);
+ session.WaitTask = WaitLoopAsync(session);
session.CancellationRegistration = cancellationToken.Register(
static state =>
{
@@ -329,8 +325,6 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella
inputStream?.Dispose();
outputStream?.Dispose();
inputRead?.Dispose();
- inputWrite?.Dispose();
- outputRead?.Dispose();
outputWrite?.Dispose();
process?.Dispose();
pseudoConsole?.Dispose();
@@ -455,14 +449,15 @@ private OrderedInputWriter GetWriter()
}
}
- private void ReadLoop(SessionResources session)
+ private async Task ReadLoopAsync(SessionResources session)
{
var buffer = new byte[16 * 1024];
try
{
while (!session.Lifetime.IsCancellationRequested)
{
- var read = session.Output.Read(buffer);
+ var read = await session.Output.ReadAsync(
+ buffer, session.Lifetime.Token).ConfigureAwait(false);
if (read == 0)
{
break;
@@ -486,13 +481,26 @@ private void ReadLoop(SessionResources session)
}
}
- private void WaitLoop(SessionResources session)
+ private async Task WaitLoopAsync(SessionResources session)
{
- var waitResult = Kernel32.WaitForSingleObject(session.Process, Kernel32.Infinite);
- if (waitResult == Kernel32.WaitFailed)
- {
- PublishFault(session, new Win32Exception(Marshal.GetLastPInvokeError()));
- return;
+ // A registered wait observes process exit without holding a pool worker
+ // (or retaining a dedicated thread's handles) for the session lifetime.
+ using (var processExited = new EventWaitHandle(false, EventResetMode.AutoReset))
+ {
+ processExited.SafeWaitHandle = new SafeWaitHandle(
+ session.Process.DangerousGetHandle(), ownsHandle: false);
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var registration = ThreadPool.RegisterWaitForSingleObject(
+ processExited, static (state, _) => ((TaskCompletionSource)state!).TrySetResult(),
+ completion, Timeout.Infinite, executeOnlyOnce: true);
+ try
+ {
+ await completion.Task.ConfigureAwait(false);
+ }
+ finally
+ {
+ registration.Unregister(null);
+ }
}
if (!Kernel32.GetExitCodeProcess(session.Process, out var code))
@@ -777,11 +785,30 @@ private static nint CreateEnvironmentBlock(TerminalLaunchOptions options)
return Marshal.StringToHGlobalUni(builder.ToString());
}
- private static void CreatePipe(out SafeFileHandle read, out SafeFileHandle write)
+ private static void CreatePipe(
+ PipeDirection direction,
+ out NamedPipeServerStream server,
+ out NamedPipeClientStream client)
{
- if (!Kernel32.CreatePipe(out read, out write, 0, 0))
+ var name = $"devolutions-terminal-{Guid.NewGuid():N}";
+ // ConPTY uses synchronous handles, but our ends must support overlapped
+ // I/O so idle reads and blocked writes remain cancellable without workers.
+ server = new NamedPipeServerStream(
+ name, direction, 1, PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ client = new NamedPipeClientStream(
+ ".", name, direction == PipeDirection.Out ? PipeDirection.In : PipeDirection.Out,
+ PipeOptions.None);
+ try
{
- throw new Win32Exception(Marshal.GetLastPInvokeError());
+ client.Connect();
+ server.WaitForConnection();
+ }
+ catch
+ {
+ client.Dispose();
+ server.Dispose();
+ throw;
}
}
@@ -848,8 +875,8 @@ private sealed class SessionResources(
TerminalProcessMetadata metadata,
SafePseudoConsoleHandle pseudoConsole,
SafeKernelObjectHandle process,
- FileStream input,
- FileStream output,
+ NamedPipeServerStream input,
+ NamedPipeServerStream output,
CancellationTokenSource lifetime)
{
public long Generation { get; } = generation;
@@ -857,8 +884,8 @@ private sealed class SessionResources(
public TerminalProcessMetadata Metadata { get; } = metadata;
public SafePseudoConsoleHandle PseudoConsole { get; } = pseudoConsole;
public SafeKernelObjectHandle Process { get; } = process;
- public FileStream Input { get; } = input;
- public FileStream Output { get; } = output;
+ public NamedPipeServerStream Input { get; } = input;
+ public NamedPipeServerStream Output { get; } = output;
public CancellationTokenSource Lifetime { get; } = lifetime;
public CancellationTokenRegistration CancellationRegistration { get; set; }
public Task? ReadTask { get; set; }
diff --git a/src/Devolutions.Terminal.Connection/Native/ConPty.cs b/src/Devolutions.Terminal.Connection/Native/ConPty.cs
index 46a987f..5dfc3da 100644
--- a/src/Devolutions.Terminal.Connection/Native/ConPty.cs
+++ b/src/Devolutions.Terminal.Connection/Native/ConPty.cs
@@ -8,7 +8,7 @@ namespace Devolutions.Terminal.Connection.Native;
internal static partial class ConPty
{
[LibraryImport("conpty.dll", EntryPoint = "ConptyCreatePseudoConsole")]
- internal static partial int CreatePseudoConsole(Kernel32.Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out nint phPC);
+ internal static partial int CreatePseudoConsole(Kernel32.Coord size, SafePipeHandle hInput, SafePipeHandle hOutput, uint dwFlags, out nint phPC);
[LibraryImport("conpty.dll", EntryPoint = "ConptyResizePseudoConsole")]
internal static partial int ResizePseudoConsole(SafePseudoConsoleHandle hPC, Kernel32.Coord size);
diff --git a/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj b/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj
index 51af038..904ec0d 100644
--- a/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj
+++ b/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj
@@ -24,6 +24,7 @@
diff --git a/src/Devolutions.Terminal.Distribution/README.md b/src/Devolutions.Terminal.Distribution/README.md
index dea11aa..9c2e5f4 100644
--- a/src/Devolutions.Terminal.Distribution/README.md
+++ b/src/Devolutions.Terminal.Distribution/README.md
@@ -17,5 +17,12 @@ MSBuild targets copy only the payload matching the consuming project's
`RuntimeIdentifier` into its output. Projects without a `RuntimeIdentifier`
continue to receive the `win-x64` payload by default.
+Signed releases and signed dry runs package the verified Windows binaries used by the MSI, not the original unsigned publish output.
+macOS payloads retain the flat executable/library layout and are signed as standalone code with Developer ID, Hardened Runtime, and secure timestamps.
+The macOS main executable is re-signed outside its `.app` so its signature does not depend on bundle-only `Info.plist` and resource seals.
+Standalone NuGet payloads do not carry the notarized app bundle's stapled ticket; use the ZIP or DMG to distribute the notarized app.
+Ordinary CI and unsigned dry runs remain credential-free; their Windows code is unsigned and macOS code is ad-hoc signed.
+Native debug symbols are excluded from the runtime packages.
+
The command-line executable is `dt.exe` on Windows and `dt` on Linux and macOS;
no `wt.exe` alias or Windows Terminal compatibility shim is installed.
diff --git a/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 b/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1
index 5ac3b76..fad00ec 100644
--- a/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1
+++ b/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1
@@ -11,7 +11,11 @@ param(
[string] $OutputDirectory,
- [switch] $SkipPublish
+ [switch] $SkipPublish,
+
+ [switch] $RequireWindowsSignature,
+
+ [string] $ExpectedPublisher
)
Set-StrictMode -Version Latest
@@ -69,6 +73,14 @@ foreach ($runtimeIdentifier in $RuntimeIdentifiers) {
throw "Published output for '$runtimeIdentifier' was not found at '$layout'."
}
}
+
+ if ($RequireWindowsSignature -and $runtimeIdentifier.StartsWith("win-", [StringComparison]::Ordinal)) {
+ $signatureArguments = @{ PayloadDirectory = $layout }
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedPublisher)) {
+ $signatureArguments.ExpectedPublisher = $ExpectedPublisher
+ }
+ & (Join-Path $PSScriptRoot "Test-WindowsPayloadSignatures.ps1") @signatureArguments
+ }
}
Get-ChildItem -LiteralPath $packageOutput -File -Filter "Devolutions.Terminal.App*.nupkg" |
@@ -80,6 +92,7 @@ foreach ($runtimeIdentifier in $RuntimeIdentifiers) {
"-c", $Configuration,
"-p:PackageVersion=$Version",
"-p:PackageOutputPath=$packageOutput\",
+ "-p:DevolutionsTerminalNugetLayoutRoot=$layoutRoot",
"-p:DevolutionsTerminalPackageRuntimeIdentifier=$runtimeIdentifier"
)
}
diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1
index a4ea187..b1a54e8 100644
--- a/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1
+++ b/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1
@@ -5,12 +5,28 @@ param(
[string] $PackageDirectory,
[ValidatePattern("^\d+\.\d+\.\d+$")]
- [string] $Version = "2026.3.0"
+ [string] $Version = "2026.3.0",
+
+ # Validate the restored Windows native payloads in the actual consumer output.
+ [switch] $RequireWindowsSignature,
+
+ [ValidateNotNullOrEmpty()]
+ [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })]
+ [string] $ExpectedPublisher
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
+if ($RequireWindowsSignature -and -not $IsWindows) {
+ throw "-RequireWindowsSignature requires Windows."
+}
+
+$signatureArguments = @{}
+if ($PSBoundParameters.ContainsKey("ExpectedPublisher")) {
+ $signatureArguments.ExpectedPublisher = $ExpectedPublisher
+}
+
$packageSource = [IO.Path]::GetFullPath($PackageDirectory)
$expectedPackageNames = @(
"Devolutions.Terminal.App.$Version.nupkg"
@@ -82,6 +98,11 @@ try {
if ($otherPayloads.Count -ne 0) {
throw "Unexpected runtime payloads were copied for '$runtimeIdentifier': $($otherPayloads.Name -join ', ')."
}
+
+ if ($RequireWindowsSignature -and $runtimeIdentifier.StartsWith("win-", [StringComparison]::Ordinal)) {
+ $payloadDirectory = Join-Path $outputDirectory "runtimes\$runtimeIdentifier\native\payload"
+ & "$PSScriptRoot\Test-WindowsPayloadSignatures.ps1" -PayloadDirectory $payloadDirectory @signatureArguments
+ }
}
@"
@@ -109,6 +130,11 @@ try {
if (-not (Test-Path -LiteralPath $defaultPayloadPath -PathType Leaf)) {
throw "Default win-x64 package payload '$defaultPayloadPath' was not copied to the consumer output."
}
+
+ if ($RequireWindowsSignature) {
+ $payloadDirectory = Join-Path $testRoot "bin\Release\net10.0\runtimes\win-x64\native\payload"
+ & "$PSScriptRoot\Test-WindowsPayloadSignatures.ps1" -PayloadDirectory $payloadDirectory @signatureArguments
+ }
}
finally {
$env:NUGET_PACKAGES = $originalNugetPackages
diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1
new file mode 100644
index 0000000..cb29203
--- /dev/null
+++ b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1
@@ -0,0 +1,62 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
+ [string] $PayloadDirectory,
+
+ # Exact certificate subject for our app executables only. Third-party binaries
+ # may legitimately have another signer; all binaries must still be trusted.
+ [ValidateNotNullOrEmpty()]
+ [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })]
+ [string] $ExpectedPublisher
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+# Inspect the native exit code ourselves, including when the caller enables this preference.
+$PSNativeCommandUseErrorActionPreference = $false
+
+if (-not $IsWindows) {
+ throw "Windows payload signature validation requires Windows."
+}
+
+$payloadPath = (Get-Item -LiteralPath $PayloadDirectory).FullName
+$appExecutableNames = @("Devolutions.Terminal.exe", "dt.exe")
+foreach ($name in $appExecutableNames) {
+ $appPath = Join-Path $payloadPath $name
+ if (-not (Test-Path -LiteralPath $appPath -PathType Leaf)) {
+ throw "Required Windows app executable '$appPath' was not found."
+ }
+}
+
+Get-Command winapp -ErrorAction Stop | Out-Null
+
+# Include nested host layouts, not just the executables at the payload root.
+$binaries = @(Get-ChildItem -LiteralPath $payloadPath -Recurse -File -Force |
+ Where-Object Extension -in @(".exe", ".dll") |
+ Sort-Object @{ Expression = { $_.Name -notin $appExecutableNames } }, FullName)
+
+foreach ($binary in $binaries) {
+ $signature = Get-AuthenticodeSignature -LiteralPath $binary.FullName
+ if ($signature.Status -ne "Valid" -or $null -eq $signature.SignerCertificate) {
+ throw "Windows payload signature validation failed for '$($binary.FullName)': Authenticode status '$($signature.Status)' (expected 'Valid'). $($signature.StatusMessage)"
+ }
+ if ($null -eq $signature.TimeStamperCertificate) {
+ throw "Windows payload signature validation failed for '$($binary.FullName)': timestamp certificate is missing."
+ }
+ if ($PSBoundParameters.ContainsKey("ExpectedPublisher") -and $binary.Name -in $appExecutableNames -and
+ $signature.SignerCertificate.Subject -cne $ExpectedPublisher) {
+ throw "Windows app signer for '$($binary.FullName)' is '$($signature.SignerCertificate.Subject)'; expected exact publisher '$ExpectedPublisher'."
+ }
+
+ $signatureOutput = & winapp tool signtool verify /pa /all /v /tw $binary.FullName 2>&1
+ $exitCode = $LASTEXITCODE
+ if ($exitCode -ne 0) {
+ $signatureOutput | Out-Host
+ throw "SignTool signature validation failed for '$($binary.FullName)' with exit code $exitCode."
+ }
+
+ Write-Host "Verified trusted, timestamped Windows payload signature: $($binary.FullName)"
+}
+
+Write-Host "Validated $($binaries.Count) Windows payload binary signatures in '$payloadPath'."
diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1
new file mode 100644
index 0000000..8cc6f50
--- /dev/null
+++ b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1
@@ -0,0 +1,163 @@
+# Focused, dependency-free contract tests. Signature/tool responses are simulated;
+# real trust and timestamp verification must also be tested with signed artifacts.
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+if (-not $IsWindows) {
+ throw "Windows payload signature regression tests require Windows."
+}
+
+$helperPath = Join-Path $PSScriptRoot "Test-WindowsPayloadSignatures.ps1"
+$testRoot = Join-Path ([IO.Path]::GetTempPath()) ("devolutions-terminal-signatures-" + [guid]::NewGuid())
+$appPath = Join-Path $testRoot "Devolutions.Terminal.exe"
+$aliasPath = Join-Path $testRoot "dt.exe"
+$libraryPath = Join-Path $testRoot "third-party.dll"
+$hostPath = Join-Path $testRoot "runtimes\win-arm64\native\arm64\OpenConsole.exe"
+$expectedPublisher = "CN=Fixture Publisher"
+$testState = @{
+ Publisher = $expectedPublisher
+ SignatureOverrides = @{}
+ SignaturePaths = [Collections.Generic.List[string]]::new()
+ ToolCalls = [Collections.Generic.List[object]]::new()
+ ToolExitCode = 0
+}
+
+function Get-AuthenticodeSignature {
+ param([string] $LiteralPath)
+
+ $testState.SignaturePaths.Add($LiteralPath)
+ $subject = if ([IO.Path]::GetFileName($LiteralPath) -in @("Devolutions.Terminal.exe", "dt.exe")) {
+ $testState.Publisher
+ } else {
+ "CN=Third-party Publisher"
+ }
+ $signature = [pscustomobject]@{
+ Status = "Valid"
+ StatusMessage = "Simulated signature result."
+ SignerCertificate = [pscustomobject]@{ Subject = $subject }
+ TimeStamperCertificate = [pscustomobject]@{ Subject = "CN=Timestamp Publisher" }
+ }
+ if ($testState.SignatureOverrides.ContainsKey($LiteralPath)) {
+ foreach ($key in $testState.SignatureOverrides[$LiteralPath].Keys) {
+ $signature.$key = $testState.SignatureOverrides[$LiteralPath][$key]
+ }
+ }
+ return $signature
+}
+
+function winapp {
+ $testState.ToolCalls.Add(@($args))
+ Set-Variable -Name LASTEXITCODE -Value $testState.ToolExitCode -Scope 1
+ "Simulated SignTool result."
+}
+
+function Assert-Rejected {
+ param(
+ [scriptblock] $Action,
+ [string] $MessagePattern
+ )
+
+ try {
+ & $Action
+ }
+ catch {
+ if ($_.Exception.Message -notmatch $MessagePattern) {
+ throw "Unexpected failure: $($_.Exception.Message); expected pattern '$MessagePattern'."
+ }
+ return
+ }
+ throw "Validation unexpectedly succeeded; expected pattern '$MessagePattern'."
+}
+
+function Test-Case {
+ param(
+ [string] $Name,
+ [scriptblock] $Action
+ )
+
+ $testState.SignatureOverrides = @{}
+ $testState.SignaturePaths.Clear()
+ $testState.ToolCalls.Clear()
+ $testState.ToolExitCode = 0
+ & $Action
+ Write-Host "PASS: $Name"
+}
+
+try {
+ New-Item -ItemType Directory -Path ([IO.Path]::GetDirectoryName($hostPath)) -Force | Out-Null
+ foreach ($path in @($appPath, $aliasPath, $libraryPath, $hostPath)) {
+ [IO.File]::WriteAllBytes($path, [byte[]]@())
+ }
+
+ Test-Case "TrustedTimestampedRecursivePayload" {
+ & $helperPath -PayloadDirectory $testRoot -ExpectedPublisher $expectedPublisher
+ if ($testState.ToolCalls.Count -ne 4 -or $testState.SignaturePaths.Count -ne 4) {
+ throw "All four binaries, including the nested host and third-party DLL, must be checked."
+ }
+ foreach ($path in @($appPath, $aliasPath, $libraryPath, $hostPath)) {
+ if ($path -notin $testState.SignaturePaths) {
+ throw "Missing Authenticode check for '$path'."
+ }
+ $calls = @($testState.ToolCalls | Where-Object { $_[-1] -eq $path })
+ if ($calls.Count -ne 1 -or ($calls[0] -join "|") -cne "tool|signtool|verify|/pa|/all|/v|/tw|$path") {
+ throw "Expected exactly one strict SignTool verification for '$path'."
+ }
+ }
+ }
+ foreach ($path in @($appPath, $aliasPath, $hostPath)) {
+ Test-Case "UnsignedBinaryRejected-$([IO.Path]::GetFileName($path))" {
+ $testState.SignatureOverrides[$path] = @{ Status = "NotSigned"; SignerCertificate = $null }
+ Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } (
+ [regex]::Escape($path) + ".*Authenticode status 'NotSigned'"
+ )
+ }
+ }
+ Test-Case "UntrustedRootRejected" {
+ $testState.SignatureOverrides[$appPath] = @{ Status = "NotTrusted" }
+ Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } "Authenticode status 'NotTrusted'"
+ }
+ Test-Case "MissingTimestampRejected" {
+ $testState.SignatureOverrides[$aliasPath] = @{ TimeStamperCertificate = $null }
+ Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } (
+ [regex]::Escape($aliasPath) + ".*timestamp certificate is missing"
+ )
+ }
+ Test-Case "PublisherMismatchRejected" {
+ $testState.SignatureOverrides[$aliasPath] = @{
+ SignerCertificate = [pscustomobject]@{ Subject = "CN=Wrong Publisher" }
+ }
+ Assert-Rejected { & $helperPath -PayloadDirectory $testRoot -ExpectedPublisher $expectedPublisher } (
+ [regex]::Escape($aliasPath) + ".*expected exact publisher"
+ )
+ }
+ Test-Case "PublisherMatchIsCaseSensitive" {
+ Assert-Rejected { & $helperPath -PayloadDirectory $testRoot -ExpectedPublisher $expectedPublisher.ToLowerInvariant() } (
+ "expected exact publisher"
+ )
+ }
+ Test-Case "SignToolFailurePropagates" {
+ $PSNativeCommandUseErrorActionPreference = $true
+ $testState.ToolExitCode = 23
+ Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } "SignTool signature validation failed.*exit code 23"
+ }
+ foreach ($path in @($appPath, $aliasPath)) {
+ Test-Case "MissingAppRejected-$([IO.Path]::GetFileName($path))" {
+ Remove-Item -LiteralPath $path
+ try {
+ Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } (
+ "Required Windows app executable '" + [regex]::Escape($path) + "' was not found"
+ )
+ }
+ finally {
+ [IO.File]::WriteAllBytes($path, [byte[]]@())
+ }
+ }
+ }
+ Write-Host "All 11 Windows payload signature regression cases passed."
+}
+finally {
+ Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
+}
diff --git a/tests/Devolutions.Terminal.Connection.Tests/ConPtySchedulingTests.cs b/tests/Devolutions.Terminal.Connection.Tests/ConPtySchedulingTests.cs
new file mode 100644
index 0000000..063cfee
--- /dev/null
+++ b/tests/Devolutions.Terminal.Connection.Tests/ConPtySchedulingTests.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics;
+using System.Runtime.Versioning;
+using System.Text;
+using Xunit;
+
+namespace Devolutions.Terminal.Connection.Tests;
+
+[SupportedOSPlatform("windows")]
+public sealed class ConPtySchedulingTests
+{
+ private const string ProbeEnvironmentVariable = "DEVOLUTIONS_CONPTY_SCHEDULING_PROBE";
+ public static bool IsWindows => OperatingSystem.IsWindows();
+
+ [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))]
+ public async Task IdleSessionsDoNotStarveInputExitOrClose()
+ {
+ if (Environment.GetEnvironmentVariable(ProbeEnvironmentVariable) == "1")
+ {
+ await RunProbeAsync();
+ return;
+ }
+
+ // Limit workers only in a child runner, never in the shared test process.
+ var start = new ProcessStartInfo("dotnet")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ start.ArgumentList.Add(typeof(ConPtySchedulingTests).Assembly.Location);
+ start.ArgumentList.Add("-method");
+ start.ArgumentList.Add($"{typeof(ConPtySchedulingTests).FullName}.{nameof(IdleSessionsDoNotStarveInputExitOrClose)}");
+ start.ArgumentList.Add("-parallel");
+ start.ArgumentList.Add("none");
+ start.ArgumentList.Add("-noAutoReporters");
+ start.Environment[ProbeEnvironmentVariable] = "1";
+ start.Environment["DOTNET_PROCESSOR_COUNT"] = "2";
+ using var process = Process.Start(start) ?? throw new InvalidOperationException("Scheduling probe did not start.");
+ var stdout = process.StandardOutput.ReadToEndAsync();
+ var stderr = process.StandardError.ReadToEndAsync();
+ try
+ {
+ await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45));
+ }
+ catch (TimeoutException)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync();
+ Assert.Fail($"ConPTY sessions exhausted the worker pool.\n{await stdout}\n{await stderr}");
+ }
+
+ Assert.True(process.ExitCode == 0, $"Scheduling probe failed:\n{await stdout}\n{await stderr}");
+ }
+
+ private static async Task RunProbeAsync()
+ {
+ ThreadPool.GetMinThreads(out _, out var minimumIo);
+ ThreadPool.GetMaxThreads(out _, out var maximumIo);
+ Assert.True(ThreadPool.SetMinThreads(2, minimumIo));
+ Assert.True(ThreadPool.SetMaxThreads(8, maximumIo));
+ var connections = new List();
+ var ready = new List();
+ var exits = new List>();
+ try
+ {
+ for (var index = 0; index < 6; index++)
+ {
+ var connection = new ConPtyConnection();
+ connections.Add(connection);
+ var output = new StringBuilder();
+ var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ connection.OutputReceived += (_, bytes) =>
+ {
+ output.Append(Encoding.UTF8.GetString(bytes.Span));
+ if (output.ToString().Contains("READY", StringComparison.Ordinal))
+ {
+ received.TrySetResult();
+ }
+ };
+ connection.Exited += (_, code) => exited.TrySetResult(code);
+ connection.Faulted += (_, error) =>
+ {
+ received.TrySetException(error);
+ exited.TrySetException(error);
+ };
+ ready.Add(received.Task);
+ exits.Add(exited.Task);
+ var comSpec = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
+ await connection.StartAsync($"\"{comSpec}\" /d /q", null, 80, 24);
+ }
+
+ foreach (var connection in connections)
+ {
+ connection.Write("echo READY\r");
+ }
+ await Task.WhenAll(ready).WaitAsync(TimeSpan.FromSeconds(10));
+ foreach (var connection in connections)
+ {
+ connection.Resize(132, 43);
+ connection.Write("exit\r");
+ }
+ var exitCodes = await Task.WhenAll(exits).WaitAsync(TimeSpan.FromSeconds(10));
+ Assert.All(exitCodes, code => Assert.Equal(0, code));
+ }
+ finally
+ {
+ await Task.WhenAll(connections.Select(connection => connection.DisposeAsync().AsTask()))
+ .WaitAsync(TimeSpan.FromSeconds(5));
+ }
+ Assert.All(connections, connection => Assert.False(connection.IsRunning));
+ }
+}
diff --git a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs
index 8364c42..822c624 100644
--- a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs
+++ b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs
@@ -741,6 +741,11 @@ public async Task ExitedSessionsReleaseHandlesBeforeConnectionDisposal()
Assert.Equal(0, await exited.Task.WaitAsync(TimeSpan.FromSeconds(10)));
}
+ // Collect managed wait/overlapped-I/O bookkeeping, while retaining
+ // every connection so leaked native session handles remain observable.
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
var deadline = DateTime.UtcNow.AddSeconds(10);
int handleCount;
do