diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml
index 654004f..792b6e3 100644
--- a/.github/workflows/build-terminal.yml
+++ b/.github/workflows/build-terminal.yml
@@ -133,6 +133,96 @@ jobs:
- name: Validate Linux scripts and metadata
run: bash scripts/Test-LinuxPackagingMetadata.sh
+ macos-managed:
+ name: macOS managed and metadata tests
+ runs-on: macos-14
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Cache native libraries
+ uses: actions/cache@v4
+ with:
+ path: |
+ artifacts/tools
+ artifacts/ghostty-src
+ native/ghostty
+ native/linux-pty
+ native/noto-emoji/NotoColorEmoji.ttf
+ key: native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }}
+ restore-keys: |
+ native-${{ runner.os }}-${{ runner.arch }}-
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore
+ run: dotnet restore Devolutions.Terminal.slnx
+
+ - name: Run managed tests
+ run: dotnet test Devolutions.Terminal.slnx -c Release --no-restore
+
+ - name: Validate macOS scripts and metadata
+ run: bash scripts/Test-MacOsPackagingMetadata.sh
+
+ macos-native-aot:
+ name: macOS NativeAOT osx-arm64
+ runs-on: macos-14
+ needs: macos-managed
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Cache native libraries
+ uses: actions/cache@v4
+ with:
+ path: |
+ artifacts/tools
+ artifacts/ghostty-src
+ native/ghostty
+ native/linux-pty
+ native/noto-emoji/NotoColorEmoji.ttf
+ key: native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }}
+ restore-keys: |
+ native-${{ runner.os }}-${{ runner.arch }}-
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: NativeAOT publish
+ run: >
+ dotnet publish src/Devolutions.Terminal/Devolutions.Terminal.csproj
+ -c Release
+ -r osx-arm64
+ --self-contained true
+ -p:DebugSymbols=false
+ -p:DebugType=None
+ -p:NativeDebugSymbols=false
+ -o artifacts/publish/osx-arm64
+
+ - name: Build app bundle and zip
+ env:
+ MACOS_PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/osx-arm64
+ SOURCE_DATE_EPOCH: "1704067200"
+ run: bash scripts/Build-MacOsPackage.sh osx-arm64 "0.1.${{ github.run_number }}" artifacts/macos-packages
+
+ - name: Validate package without launching UI
+ run: bash scripts/Test-MacOsPackage.sh osx-arm64 artifacts/macos-packages/*.zip
+
+ - name: Run native non-UI gates
+ run: bash scripts/Test-MacOsRuntime.sh artifacts/macos-packages
+
+ - name: Upload macOS package artifacts
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: DevolutionsTerminal-osx-arm64-packages
+ path: artifacts/macos-packages
+ if-no-files-found: error
+
linux-packages:
name: Linux packages ${{ matrix.rid }}
runs-on: ubuntu-24.04
@@ -166,7 +256,8 @@ jobs:
if: matrix.rid == 'linux-arm64'
run: |
sudo apt-get install --no-install-recommends \
- binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu
+ binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu \
+ libc6-dev-arm64-cross
- name: Fetch pinned AppImage runtime
env:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 662782f..c8a8e6e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -45,7 +45,7 @@ Do not point that tool at this repository.
## Native helpers
-- Linux/macOS PTY host: `native/linux-pty` (`dt-pty-host.c`, Zig `cc`)
+- Linux/macOS PTY host: `native/linux-pty` (`dt-pty-host.c`; Zig `cc` on Linux, Apple clang on macOS)
- Ghostty VT library: `native/ghostty` (Zig build of pinned Ghostty)
- Windows Explorer/toast helpers: `native/windows-shell` (MSVC, gitignored `bin/`)
@@ -56,6 +56,13 @@ downloaded into `artifacts/tools` on first use). See
## macOS
-macOS is partial. See [docs/macos.md](docs/macos.md). The Unix PTY host source
-cross-targets `osx-arm64` / `osx-x64`; Ghostty dylibs and app-bundle packaging
-are not in this tree yet.
+See [docs/macos.md](docs/macos.md). On a Mac:
+
+```bash
+dotnet test Devolutions.Terminal.slnx
+scripts/Build-MacOsPackage.sh osx-arm64 0.1.0 artifacts/packages
+```
+
+`dotnet build` restores Ghostty and `dt-pty-host` for `osx-arm64` / `osx-x64`.
+The PTY host is compiled with Apple clang against the macOS 13 SDK. App-bundle
+packaging is Darwin-only; notarization, DMG, and Homebrew are not included.
diff --git a/README.md b/README.md
index f9c33b7..8a66cf2 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,13 @@ dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained
The native executable is written to
`src/Devolutions.Terminal/bin/Release/net10.0/win-x64/publish/Devolutions.Terminal.exe`.
+macOS NativeAOT app bundles (Darwin only):
+
+```bash
+scripts/Build-MacOsPackage.sh osx-arm64 0.1.0 artifacts/packages
+bash scripts/Test-MacOsPackage.sh osx-arm64 artifacts/packages/*.zip
+```
+
Linux x64 and ARM64 NativeAOT packages are built on Linux with:
```bash
@@ -61,8 +68,9 @@ Development signing, trust, install, validation, and uninstall commands are in
## Settings and engines
Settings are stored at `%LOCALAPPDATA%\Devolutions\Terminal\settings.json` on
-Windows and under `$XDG_CONFIG_HOME/devolutions-terminal` on Linux, with the
-usual `~/.config` fallback.
+Windows, under `$XDG_CONFIG_HOME/devolutions-terminal` on Linux (usual
+`~/.config` fallback), and at
+`~/Library/Application Support/Devolutions/Terminal/` on macOS.
Set `WT_BASE_SETTINGS_PATH` to use a directory for `settings.json` and
`state.json` (same contract as Devolutions' Windows Terminal distribution).
Set `DTERM_SETTINGS_PATH` (or `WT_DOTNET_SETTINGS_PATH`) to load a specific
diff --git a/docs/macos.md b/docs/macos.md
index cdef934..f134d7b 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -1,34 +1,48 @@
# macOS support
-macOS is a partial port. The managed app, built-in VT engine, settings, and
-Unix PTY protocol are wired. NativeAOT publish and live UI have not been run
-on a Mac in this tree.
+macOS is a first-class host for the managed app, Unix PTY transport, built-in
+and Ghostty engines, and NativeAOT `.app` packaging. Global hotkeys, default
+terminal registration, notarization, DMG, and Homebrew remain out of scope.
-## What should work
+## What works
- Avalonia desktop host (`osx-arm64` / `osx-x64`)
- Local shells through `dt-pty-host` (`forkpty`, same framing as Linux)
+- Selectable built-in and Ghostty engines (`libghostty-vt.dylib`)
- Settings at `~/Library/Application Support/Devolutions/Terminal/`
- Generated zsh/bash/fish/pwsh/sh profiles (`Devolutions.Terminal.macOS`)
- Hidden Windows inbox profiles that use `%SystemRoot%`
- Opening files and URIs with `open(1)`
- Notifications through `osascript` `display notification`
- `dterm:` URL scheme declared in `macos/Info.plist`
+- NativeAOT `.app` + zip packaging on Darwin
## Not bundled yet
-- App bundle / notarization / DMG / Homebrew cask
+- Notarization / DMG / Homebrew cask
- Global hotkeys (broker / `dt -w` still work)
- Default-terminal registration
Ghostty dylibs and `dt-pty-host` are built on restore for `osx-arm64` /
-`osx-x64` (macOS 13+).
+`osx-x64` (macOS 13+). `dt-pty-host` is compiled with Apple clang so it can
+link `libutil` from the SDK.
## Build on a Mac
```bash
+dotnet test Devolutions.Terminal.slnx
dotnet publish src/Devolutions.Terminal -c Release -r osx-arm64 --self-contained
+scripts/Build-MacOsPackage.sh osx-arm64 0.1.0 artifacts/packages
+bash scripts/Test-MacOsPackage.sh osx-arm64 artifacts/packages/*.zip
+bash scripts/Test-MacOsRuntime.sh artifacts/packages
+```
+
+`Build-MacOsPackage.sh` publishes NativeAOT unless `MACOS_PUBLISH_DIR` is set,
+stages `Devolutions Terminal.app` with `macos/Info.plist`, generates
+`DevolutionsTerminal.icns` from the hicolor PNGs, ad-hoc signs the bundle, and
+writes a zip plus SHA-256 manifest.
+
+```bash
+open "artifacts/packages/Devolutions Terminal.app"
```
-Copy `macos/Info.plist` into the `.app` bundle when packaging. Publish restores
-`dt-pty-host` and `libghostty-vt.dylib` for the RID.
diff --git a/docs/parity-status.md b/docs/parity-status.md
index fe3bb4a..c1fdc27 100644
--- a/docs/parity-status.md
+++ b/docs/parity-status.md
@@ -14,11 +14,11 @@ and NativeAOT gates completed before the final live UI matrix.
| --- | --- |
| Windows process transport | ConPTY with input, resize, cancellation, restart, exit metadata, and x64/ARM64 NativeAOT |
| Linux process transport | Bundled `forkpty` relay with input, resize, cancellation, restart, exit metadata, and x64/ARM64 NativeAOT |
-| macOS process transport | Same Unix `forkpty` host (`osx-arm64`/`osx-x64` Zig targets); binaries and live NativeAOT UI not produced in this tree |
-| Terminal engines | Selectable built-in and pinned Ghostty engines on Windows and Linux; Ghostty dylib not bundled on macOS |
+| macOS process transport | Same Unix `forkpty` host (`osx-arm64`/`osx-x64`); Apple clang `dt-pty-host` plus NativeAOT `.app` packaging on Darwin |
+| Terminal engines | Selectable built-in and pinned Ghostty engines on Windows, Linux, and macOS |
| Application shell | Multi-window broker, tabs, panes, settings editor, palettes, tray behavior, accessibility, clipboard, and notifications |
| Settings | Layering, dynamic profiles, fragments, source-generated JSON, Windows paths, XDG paths, macOS Application Support, and state persistence |
-| Distribution | Windows x64/ARM64 MSIX and bundle; reproducible Linux x64/ARM64 tar, DEB, RPM, and AppImage packages with canonical freedesktop assets, licenses, checksums, inventory/SPDX SBOM, and DESTDIR-aware helpers |
+| Distribution | Windows x64/ARM64 MSIX and bundle; reproducible Linux x64/ARM64 tar, DEB, RPM, and AppImage packages with canonical freedesktop assets, licenses, checksums, inventory/SPDX SBOM, and DESTDIR-aware helpers; macOS NativeAOT `.app` and zip on Darwin |
## Actions
@@ -127,3 +127,7 @@ truecolor, Unicode/CJK/emoji, row rendition, images, and shader effects.
provide a visible file-manager handler.
- Native Windows ARM64 and Linux ARM64 remain non-UI hardware/package gates;
no ARM64 desktop was available in this session.
+- macOS ARM64 NativeAOT `.app` packaging, `dt` parser, Ghostty ABI, real
+ `forkpty`, broker named-pipe length, and a live GUI-host process start were
+ validated on Darwin. Notarization, DMG, Homebrew, global hotkeys, and
+ default-terminal registration remain out of scope.
diff --git a/docs/release.md b/docs/release.md
index 7ecdaf0..df9d25d 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -6,21 +6,22 @@ Commands are relative to the repository root.
- Windows 10 version 2004 (build 19041) or later for Windows builds
- glibc-based x64 or ARM64 Linux for Linux builds
+- macOS 13+ with Xcode CLT for macOS NativeAOT and `.app` packaging
- .NET SDK selected by `global.json`
- Visual Studio 2022/2026 Build Tools with Desktop C++ for NativeAOT linking
- WinApp CLI 0.6.0 for MSIX creation and validation
- A trusted code-signing certificate for distributable MSIX artifacts
-Windows local sessions use ConPTY. Linux local sessions use the bundled
-`forkpty` relay. The Avalonia shell, settings, renderer, and terminal engines
-are shared.
+Windows local sessions use ConPTY. Linux and macOS local sessions use the
+bundled `forkpty` relay. The Avalonia shell, settings, renderer, and terminal
+engines are shared.
CI workflows:
- `build-ghostty.yml` — compile `libghostty-vt` for every RID and upload
artifacts (optional cache; not required to develop).
- `build-terminal.yml` — restore natives from source, test, NativeAOT, Linux
- packages, MSIX.
+ packages, macOS `.app`/zip, MSIX.
## Developer build
@@ -54,6 +55,21 @@ MSIX staging additionally builds `dt-shell-integration.exe` and
`Devolutions.Terminal.ShellExt.dll` for the package architecture with the installed
MSVC/Windows SDK toolchain.
+macOS NativeAOT app bundles (Darwin only):
+
+```bash
+dotnet publish src/Devolutions.Terminal -c Release -r osx-arm64 --self-contained \
+ -o artifacts/native/osx-arm64
+MACOS_PUBLISH_DIR="$PWD/artifacts/native/osx-arm64" \
+ bash scripts/Build-MacOsPackage.sh osx-arm64 0.1.0 artifacts/packages
+bash scripts/Test-MacOsPackage.sh osx-arm64 artifacts/packages/*.zip
+bash scripts/Test-MacOsRuntime.sh artifacts/packages
+```
+
+The staged `Devolutions Terminal.app` contains `Devolutions.Terminal`, `dt`,
+`dt-pty-host`, `libghostty-vt.dylib`, Skia, HarfBuzz, `Info.plist`, and an
+ad-hoc signed icon. Notarization, DMG, and Homebrew are not part of this gate.
+
Linux package formats:
```bash
diff --git a/macos/Info.plist b/macos/Info.plist
index 6587930..ce0a0b2 100644
--- a/macos/Info.plist
+++ b/macos/Info.plist
@@ -16,10 +16,14 @@
Devolutions Terminal
CFBundlePackageType
APPL
+ CFBundleIconFile
+ DevolutionsTerminal
CFBundleShortVersionString
0.1.0
CFBundleVersion
0.1.0
+ LSApplicationCategoryType
+ public.app-category.developer-tools
LSMinimumSystemVersion
13.0
NSHighResolutionCapable
diff --git a/macos/package.env b/macos/package.env
new file mode 100644
index 0000000..42b2712
--- /dev/null
+++ b/macos/package.env
@@ -0,0 +1,15 @@
+# Canonical macOS package metadata. Keep versions out of this file; callers pass
+# the version from Directory.Build.props or the release pipeline.
+PACKAGE_NAME=devolutions-terminal
+APP_ID=com.devolutions.Terminal
+DISPLAY_NAME="Devolutions Terminal"
+BUNDLE_NAME="Devolutions Terminal.app"
+EXECUTABLE_NAME=Devolutions.Terminal
+CLI_NAME=dt
+PTY_HOST_NAME=dt-pty-host
+GHOSTTY_LIBRARY=libghostty-vt.dylib
+ICON_NAME=DevolutionsTerminal
+MACOS_DEPLOYMENT_TARGET=13.0
+LICENSE_ID="MIT AND OFL-1.1"
+SBOM_LICENSE_ID="MIT AND OFL-1.1"
+URL_SCHEME=dterm
diff --git a/native/NativeLibraries.targets b/native/NativeLibraries.targets
index 8477c81..2b71476 100644
--- a/native/NativeLibraries.targets
+++ b/native/NativeLibraries.targets
@@ -19,7 +19,7 @@
Condition="'$(SkipNativeRestore)' != 'true' and '$(DesignTimeBuild)' != 'true' and '$(NativeRestore)' != 'none'">
+ LogStandardErrorAsError="false" />
+
+
diff --git a/native/Restore-NativeLibraries.ps1 b/native/Restore-NativeLibraries.ps1
index db85501..5542778 100644
--- a/native/Restore-NativeLibraries.ps1
+++ b/native/Restore-NativeLibraries.ps1
@@ -55,15 +55,15 @@ try {
$fileName = [string]$manifest.targets.$Rid.file
$output = Join-Path $nativeRoot "ghostty\$Rid\$fileName"
if ($Force -or -not (Test-Path -LiteralPath $output)) {
- $ghosttyArgs = @(
- "-Rid", $Rid,
- "-ZigPath", $ZigPath
- )
+ $ghosttyArgs = @{
+ Rid = $Rid
+ ZigPath = $ZigPath
+ }
if ($Force) {
- $ghosttyArgs += "-Force"
+ $ghosttyArgs.Force = $true
}
- & (Join-Path $nativeRoot "ghostty\Build-Ghostty.ps1") @ghosttyArgs
+ & (Join-Path (Join-Path $nativeRoot "ghostty") "Build-Ghostty.ps1") @ghosttyArgs
if ($LASTEXITCODE -ne 0) {
throw "Build-Ghostty.ps1 failed for $Rid."
}
@@ -76,15 +76,15 @@ try {
if ($buildPty) {
$output = Join-Path $nativeRoot "linux-pty\$Rid\dt-pty-host"
if ($Force -or -not (Test-Path -LiteralPath $output)) {
- $ptyArgs = @(
- "-Rid", $Rid,
- "-ZigPath", $ZigPath
- )
+ $ptyArgs = @{
+ Rid = $Rid
+ ZigPath = $ZigPath
+ }
if ($Force) {
- $ptyArgs += "-Force"
+ $ptyArgs.Force = $true
}
- & (Join-Path $nativeRoot "linux-pty\Build-LinuxPtyHost.ps1") @ptyArgs
+ & (Join-Path (Join-Path $nativeRoot "linux-pty") "Build-LinuxPtyHost.ps1") @ptyArgs
if ($LASTEXITCODE -ne 0) {
throw "Build-LinuxPtyHost.ps1 failed for $Rid."
}
diff --git a/native/ghostty/Build-Ghostty.ps1 b/native/ghostty/Build-Ghostty.ps1
index 0a237ff..7e9476f 100644
--- a/native/ghostty/Build-Ghostty.ps1
+++ b/native/ghostty/Build-Ghostty.ps1
@@ -184,6 +184,18 @@ foreach ($currentRid in $selected) {
$linux = $currentRid.StartsWith("linux-", [StringComparison]::Ordinal)
if ($linux -and $manifest.strip) {
$stripTool = Resolve-LlvmStrip -Explicit $LlvmStripPath
+ if ($currentRid -eq "linux-arm64" -and
+ [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne "Arm64") {
+ $crossStrip = Get-Command aarch64-linux-gnu-strip -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if ($null -ne $crossStrip) {
+ $stripTool = $crossStrip.Source
+ }
+ elseif ($stripTool -and
+ [IO.Path]::GetFileNameWithoutExtension($stripTool) -eq "strip") {
+ $stripTool = $null
+ }
+ }
if ([string]::IsNullOrWhiteSpace($stripTool)) {
$requiresHash = @($target.PSObject.Properties.Name) -contains "sha256" -and
-not [string]::IsNullOrWhiteSpace([string]$target.sha256)
@@ -202,7 +214,19 @@ foreach ($currentRid in $selected) {
}
}
- $hash = (Get-FileHash -LiteralPath $built -Algorithm SHA256).Hash
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $stream = [IO.File]::OpenRead($built)
+ try {
+ $hash = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace("-", "")
+ }
+ finally {
+ $stream.Dispose()
+ }
+ }
+ finally {
+ $sha.Dispose()
+ }
$expected = $null
if (@($target.PSObject.Properties.Name) -contains "sha256") {
$expected = [string]$target.sha256
diff --git a/native/linux-pty/Build-LinuxPtyHost.ps1 b/native/linux-pty/Build-LinuxPtyHost.ps1
index 87c9d69..218c120 100644
--- a/native/linux-pty/Build-LinuxPtyHost.ps1
+++ b/native/linux-pty/Build-LinuxPtyHost.ps1
@@ -42,22 +42,30 @@ foreach ($currentRid in $selected) {
continue
}
- if ($currentRid.StartsWith("osx-", [StringComparison]::Ordinal) -and (Test-HostMacOS)) {
- $sdk = & xcrun --show-sdk-path
- if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sdk)) {
- throw "xcrun --show-sdk-path failed; Xcode CLT is required for macOS dt-pty-host."
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $output) | Out-Null
+ if ($currentRid.StartsWith("osx-", [StringComparison]::Ordinal)) {
+ if (-not (Test-HostMacOS)) {
+ throw "dt-pty-host for '$currentRid' must be built on macOS so clang can link libutil from the SDK."
}
- $env:SDKROOT = $sdk.Trim()
+ $arch = if ($currentRid -eq "osx-arm64") { "arm64" } else { "x86_64" }
+ & cc @(
+ "-arch", $arch,
+ "-mmacosx-version-min=13.0",
+ "-O2",
+ (Join-Path $nativeRoot "dt-pty-host.c"),
+ "-lutil",
+ "-o", $output
+ )
+ }
+ else {
+ & $ZigPath cc `
+ "-target" $targets[$currentRid] `
+ -O2 `
+ (Join-Path $nativeRoot "dt-pty-host.c") `
+ -lutil `
+ -o $output
}
-
- New-Item -ItemType Directory -Force -Path (Split-Path -Parent $output) | Out-Null
- & $ZigPath cc `
- "-target" $targets[$currentRid] `
- -O2 `
- (Join-Path $nativeRoot "dt-pty-host.c") `
- -lutil `
- -o $output
if ($LASTEXITCODE -ne 0) {
throw "Failed to build dt-pty-host for $currentRid."
}
diff --git a/scripts/Build-MacOsPackage.sh b/scripts/Build-MacOsPackage.sh
new file mode 100755
index 0000000..9bb4eb1
--- /dev/null
+++ b/scripts/Build-MacOsPackage.sh
@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+set -euo pipefail
+export LC_ALL=C
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd -- "$script_dir/.." && pwd)"
+project="$repo_root/src/Devolutions.Terminal/Devolutions.Terminal.csproj"
+metadata="$repo_root/macos/package.env"
+
+rid="${1:-osx-arm64}"
+version="${2:-0.1.0}"
+output_dir="${3:-$repo_root/artifacts/packages}"
+
+# shellcheck source=../macos/package.env
+source "$metadata"
+case "$rid" in
+ osx-arm64) expected_arch="arm64" ;;
+ osx-x64) expected_arch="x86_64" ;;
+ *)
+ echo "Unsupported macOS RID: $rid" >&2
+ exit 64
+ ;;
+esac
+[[ "$version" =~ ^[0-9][0-9A-Za-z.+~_-]*$ ]] ||
+ { echo "Invalid macOS package version: $version" >&2; exit 64; }
+[[ "$(uname -s)" == "Darwin" ]] ||
+ { echo "macOS packages must be built on Darwin." >&2; exit 78; }
+for command in file lipo python3 codesign ditto shasum; do
+ command -v "$command" >/dev/null 2>&1 ||
+ { echo "$command is required to build macOS packages." >&2; exit 69; }
+done
+
+source_date_epoch="${SOURCE_DATE_EPOCH:-}"
+if [[ -z "$source_date_epoch" ]]; then
+ source_date_epoch="$(git -C "$repo_root" log -1 --format=%ct 2>/dev/null || python3 -c 'import time; print(int(time.time()))')"
+fi
+[[ "$source_date_epoch" =~ ^[0-9]+$ ]] ||
+ { echo "SOURCE_DATE_EPOCH must be a non-negative integer." >&2; exit 64; }
+export SOURCE_DATE_EPOCH="$source_date_epoch"
+
+work="$repo_root/artifacts/macos-package-staging/$rid-$$"
+publish_dir="$work/publish"
+app_path="$work/$BUNDLE_NAME"
+rm -rf -- "$work"
+mkdir -p "$publish_dir"
+trap 'rm -rf -- "$work"' EXIT
+
+if [[ -n "${MACOS_PUBLISH_DIR:-}" ]]; then
+ [[ -d "$MACOS_PUBLISH_DIR" ]] ||
+ { echo "MACOS_PUBLISH_DIR does not exist: $MACOS_PUBLISH_DIR" >&2; exit 66; }
+ cp -a "$MACOS_PUBLISH_DIR/." "$publish_dir/"
+else
+ command -v dotnet >/dev/null 2>&1 ||
+ { echo "dotnet is required to publish the application; install the SDK selected by global.json or set MACOS_PUBLISH_DIR." >&2; exit 69; }
+ dotnet publish "$project" \
+ -c Release \
+ -r "$rid" \
+ --self-contained true \
+ -o "$publish_dir" \
+ -p:DebugSymbols=false \
+ -p:DebugType=None \
+ -p:NativeDebugSymbols=false \
+ --verbosity minimal
+fi
+
+find "$publish_dir" -type f \( -name '*.dbg' -o -name '*.pdb' \) -delete
+for artifact in "$EXECUTABLE_NAME" "$CLI_NAME" "$PTY_HOST_NAME" "$GHOSTTY_LIBRARY" \
+ libSkiaSharp.dylib libHarfBuzzSharp.dylib; do
+ [[ -f "$publish_dir/$artifact" ]] ||
+ { echo "Publish output is missing $artifact." >&2; exit 70; }
+ archs="$(lipo -archs "$publish_dir/$artifact" 2>/dev/null || true)"
+ [[ "$archs" == *"$expected_arch"* ]] ||
+ { echo "$artifact has the wrong architecture for $rid (lipo: ${archs:-unknown})." >&2; exit 70; }
+done
+
+bash "$script_dir/Stage-MacOsApp.sh" "$publish_dir" "$app_path" "$version" "$rid"
+codesign --force --deep --sign - "$app_path"
+
+mkdir -p "$output_dir"
+output_dir="$(cd -- "$output_dir" && pwd)"
+base="$PACKAGE_NAME-$version-$rid"
+app_output="$output_dir/$BUNDLE_NAME"
+rm -rf -- "$app_output"
+cp -a "$app_path" "$app_output"
+
+archive="$output_dir/$base.zip"
+rm -f -- "$archive"
+ditto -c -k --keepParent --norsrc --noextattr --noacl "$app_output" "$archive"
+(
+ cd "$output_dir"
+ shasum -a 256 "$base.zip" "$BUNDLE_NAME/Contents/MacOS/$EXECUTABLE_NAME" \
+ "$BUNDLE_NAME/Contents/MacOS/$CLI_NAME" \
+ "$BUNDLE_NAME/Contents/MacOS/$PTY_HOST_NAME" \
+ "$BUNDLE_NAME/Contents/MacOS/$GHOSTTY_LIBRARY" |
+ sort >"$base.sha256"
+)
+
+echo "Built $archive"
diff --git a/scripts/Stage-MacOsApp.sh b/scripts/Stage-MacOsApp.sh
new file mode 100755
index 0000000..caf90ff
--- /dev/null
+++ b/scripts/Stage-MacOsApp.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd -- "$script_dir/.." && pwd)"
+metadata="$repo_root/macos/package.env"
+
+if (($# != 4)); then
+ echo "Usage: $0 " >&2
+ exit 64
+fi
+
+publish_dir="$(cd -- "$1" && pwd)"
+app_path="$2"
+version="$3"
+rid="$4"
+
+# shellcheck source=../macos/package.env
+source "$metadata"
+
+[[ "$version" =~ ^[0-9][0-9A-Za-z.+~_-]*$ ]] ||
+ { echo "Invalid macOS package version: $version" >&2; exit 64; }
+case "$rid" in
+ osx-arm64|osx-x64) ;;
+ *) echo "Unsupported macOS RID: $rid" >&2; exit 64 ;;
+esac
+command -v python3 >/dev/null 2>&1 ||
+ { echo "python3 is required to stage the macOS app bundle." >&2; exit 69; }
+command -v sips >/dev/null 2>&1 ||
+ { echo "sips is required to generate the macOS app icon." >&2; exit 69; }
+command -v iconutil >/dev/null 2>&1 ||
+ { echo "iconutil is required to generate the macOS app icon." >&2; exit 69; }
+command -v plutil >/dev/null 2>&1 ||
+ { echo "plutil is required to validate Info.plist." >&2; exit 69; }
+
+required=(
+ "$EXECUTABLE_NAME"
+ "$CLI_NAME"
+ "$PTY_HOST_NAME"
+ "$GHOSTTY_LIBRARY"
+ libSkiaSharp.dylib
+ libHarfBuzzSharp.dylib
+ THIRD-PARTY-NOTICES-GHOSTTY.txt
+ THIRD-PARTY-NOTICES-NOTO-EMOJI.txt
+)
+for path in "${required[@]}"; do
+ [[ -f "$publish_dir/$path" ]] ||
+ { echo "NativeAOT publish output is missing $path for $rid." >&2; exit 70; }
+done
+
+rm -rf -- "$app_path"
+contents="$app_path/Contents"
+macos_dir="$contents/MacOS"
+resources="$contents/Resources"
+install -d "$macos_dir" "$resources"
+cp -a "$publish_dir/." "$macos_dir/"
+find "$macos_dir" -name '*.pdb' -delete
+find "$macos_dir" -name '*.dbg' -delete
+find "$macos_dir" -name '*.dSYM' -prune -exec rm -rf {} +
+
+python3 - "$repo_root/macos/Info.plist" "$contents/Info.plist" "$version" <<'PY'
+import pathlib
+import sys
+
+source, destination, version = sys.argv[1:]
+text = pathlib.Path(source).read_text(encoding="utf-8")
+text = text.replace(
+ "0.1.0",
+ f"{version}",
+ 2,
+)
+pathlib.Path(destination).write_text(text, encoding="utf-8")
+PY
+plutil -lint "$contents/Info.plist" >/dev/null
+
+icon_work="$(mktemp -d "${TMPDIR:-/tmp}/devolutions-terminal-icon.XXXXXX")"
+iconset="$icon_work/DevolutionsTerminal.iconset"
+mkdir -p "$iconset"
+icons="$repo_root/linux/icons"
+sips -z 16 16 "$icons/$APP_ID-16.png" --out "$iconset/icon_16x16.png" >/dev/null
+sips -z 32 32 "$icons/$APP_ID-32.png" --out "$iconset/icon_16x16@2x.png" >/dev/null
+sips -z 32 32 "$icons/$APP_ID-32.png" --out "$iconset/icon_32x32.png" >/dev/null
+sips -z 64 64 "$icons/$APP_ID-64.png" --out "$iconset/icon_32x32@2x.png" >/dev/null
+sips -z 128 128 "$icons/$APP_ID-256.png" --out "$iconset/icon_128x128.png" >/dev/null
+sips -z 256 256 "$icons/$APP_ID-256.png" --out "$iconset/icon_128x128@2x.png" >/dev/null
+sips -z 256 256 "$icons/$APP_ID-256.png" --out "$iconset/icon_256x256.png" >/dev/null
+sips -z 512 512 "$icons/$APP_ID-256.png" --out "$iconset/icon_256x256@2x.png" >/dev/null
+sips -z 512 512 "$icons/$APP_ID-256.png" --out "$iconset/icon_512x512.png" >/dev/null
+sips -z 1024 1024 "$icons/$APP_ID-256.png" --out "$iconset/icon_512x512@2x.png" >/dev/null
+iconutil -c icns "$iconset" -o "$resources/$ICON_NAME.icns"
+rm -rf -- "$icon_work"
+
+install -m 0644 "$repo_root/LICENSE" "$resources/LICENSE"
+install -m 0644 "$macos_dir/THIRD-PARTY-NOTICES-GHOSTTY.txt" \
+ "$resources/THIRD-PARTY-NOTICES-GHOSTTY.txt"
+install -m 0644 "$macos_dir/THIRD-PARTY-NOTICES-NOTO-EMOJI.txt" \
+ "$resources/THIRD-PARTY-NOTICES-NOTO-EMOJI.txt"
+
+chmod 0755 "$macos_dir/$EXECUTABLE_NAME" "$macos_dir/$CLI_NAME" "$macos_dir/$PTY_HOST_NAME"
+
+source_date_epoch="${SOURCE_DATE_EPOCH:-}"
+if [[ -z "$source_date_epoch" ]]; then
+ source_date_epoch="$(git -C "$repo_root" log -1 --format=%ct 2>/dev/null || python3 -c 'import time; print(int(time.time()))')"
+fi
+[[ "$source_date_epoch" =~ ^[0-9]+$ ]] ||
+ { echo "SOURCE_DATE_EPOCH must be a non-negative integer." >&2; exit 64; }
+export SOURCE_DATE_EPOCH="$source_date_epoch"
+python3 - "$app_path" "$source_date_epoch" <<'PY'
+import os
+import sys
+from pathlib import Path
+
+root = Path(sys.argv[1])
+epoch = int(sys.argv[2])
+for path in sorted(root.rglob("*"), key=lambda item: len(item.parts), reverse=True):
+ os.utime(path, (epoch, epoch), follow_symlinks=False)
+os.utime(root, (epoch, epoch), follow_symlinks=False)
+PY
diff --git a/scripts/Test-MacOsPackage.sh b/scripts/Test-MacOsPackage.sh
new file mode 100755
index 0000000..338e476
--- /dev/null
+++ b/scripts/Test-MacOsPackage.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+set -euo pipefail
+export LC_ALL=C
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd -- "$script_dir/.." && pwd)"
+metadata="$repo_root/macos/package.env"
+
+if (($# < 2)); then
+ echo "Usage: $0 [app-or-zip ...]" >&2
+ exit 64
+fi
+rid="$1"
+shift
+
+# shellcheck source=../macos/package.env
+source "$metadata"
+case "$rid" in
+ osx-arm64) expected_arch="arm64" ;;
+ osx-x64) expected_arch="x86_64" ;;
+ *) echo "Unsupported macOS RID: $rid" >&2; exit 64 ;;
+esac
+[[ "$(uname -s)" == "Darwin" ]] ||
+ { echo "macOS package validation requires Darwin." >&2; exit 78; }
+for command in ditto file lipo plutil python3; do
+ command -v "$command" >/dev/null 2>&1 ||
+ { echo "$command is required for macOS package validation." >&2; exit 69; }
+done
+
+work="$repo_root/artifacts/macos-package-validation/$rid-$$"
+rm -rf -- "$work"
+mkdir -p "$work"
+trap 'rm -rf -- "$work"' EXIT
+
+validate_app() {
+ local app="$1"
+ local label
+ label="$(basename "$app")"
+ local macos_dir="$app/Contents/MacOS"
+ local plist="$app/Contents/Info.plist"
+ local icns="$app/Contents/Resources/$ICON_NAME.icns"
+
+ for path in \
+ "$macos_dir/$EXECUTABLE_NAME" "$macos_dir/$CLI_NAME" "$macos_dir/$PTY_HOST_NAME" \
+ "$macos_dir/$GHOSTTY_LIBRARY" "$macos_dir/libSkiaSharp.dylib" \
+ "$macos_dir/libHarfBuzzSharp.dylib" \
+ "$macos_dir/THIRD-PARTY-NOTICES-GHOSTTY.txt" \
+ "$macos_dir/THIRD-PARTY-NOTICES-NOTO-EMOJI.txt" \
+ "$app/Contents/Resources/LICENSE" "$plist" "$icns"; do
+ [[ -e "$path" ]] ||
+ { echo "$label is missing ${path#"$app/"}." >&2; exit 1; }
+ done
+ [[ -x "$macos_dir/$EXECUTABLE_NAME" ]] ||
+ { echo "$label $EXECUTABLE_NAME is not executable." >&2; exit 1; }
+ [[ -x "$macos_dir/$CLI_NAME" ]] ||
+ { echo "$label $CLI_NAME is not executable." >&2; exit 1; }
+ [[ -x "$macos_dir/$PTY_HOST_NAME" ]] ||
+ { echo "$label $PTY_HOST_NAME is not executable." >&2; exit 1; }
+
+ plutil -lint "$plist" >/dev/null
+ python3 - "$plist" "$APP_ID" "$EXECUTABLE_NAME" "$MACOS_DEPLOYMENT_TARGET" "$URL_SCHEME" <<'PY'
+import plistlib
+import sys
+
+path, app_id, executable, minimum, scheme = sys.argv[1:]
+with open(path, "rb") as handle:
+ plist = plistlib.load(handle)
+assert plist["CFBundleIdentifier"] == app_id
+assert plist["CFBundleExecutable"] == executable
+assert plist["CFBundlePackageType"] == "APPL"
+assert plist["LSMinimumSystemVersion"] == minimum
+assert plist["NSHighResolutionCapable"] is True
+assert scheme in plist["CFBundleURLTypes"][0]["CFBundleURLSchemes"]
+assert plist["CFBundleIconFile"] == "DevolutionsTerminal"
+PY
+
+ for binary in "$EXECUTABLE_NAME" "$CLI_NAME" "$PTY_HOST_NAME" "$GHOSTTY_LIBRARY" \
+ libSkiaSharp.dylib libHarfBuzzSharp.dylib; do
+ archs="$(lipo -archs "$macos_dir/$binary")"
+ [[ "$archs" == *"$expected_arch"* ]] ||
+ { echo "$label $binary has the wrong architecture (lipo: $archs)." >&2; exit 1; }
+ file -b "$macos_dir/$binary" | grep -E 'Mach-O' >/dev/null ||
+ { echo "$label $binary is not Mach-O." >&2; exit 1; }
+ done
+
+ if find "$app" -type f \( -name '*.pdb' -o -name '*.dbg' -o -name '*.key' \
+ -o -name '*.pfx' -o -name '*.pem' \) | grep -q .; then
+ echo "$label contains debug or private-key files." >&2
+ exit 1
+ fi
+ if find "$app" -name '*.dSYM' | grep -q .; then
+ echo "$label contains dSYM bundles." >&2
+ exit 1
+ fi
+}
+
+for package in "$@"; do
+ [[ -e "$package" ]] ||
+ { echo "Package not found: $package" >&2; exit 66; }
+ package="$(cd -- "$(dirname -- "$package")" && pwd)/$(basename -- "$package")"
+ case "$package" in
+ *.zip)
+ extract="$work/$(basename "$package" .zip)"
+ mkdir -p "$extract"
+ ditto -x -k "$package" "$extract"
+ app="$(find "$extract" -maxdepth 2 -name '*.app' -print -quit)"
+ [[ -n "$app" ]] ||
+ { echo "$(basename "$package") does not contain an app bundle." >&2; exit 1; }
+ validate_app "$app"
+ ;;
+ *.app)
+ validate_app "$package"
+ ;;
+ *)
+ echo "Unsupported macOS package: $package" >&2
+ exit 64
+ ;;
+ esac
+ echo "Validated $(basename "$package")"
+done
diff --git a/scripts/Test-MacOsPackagingMetadata.sh b/scripts/Test-MacOsPackagingMetadata.sh
new file mode 100755
index 0000000..1938743
--- /dev/null
+++ b/scripts/Test-MacOsPackagingMetadata.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd -- "$script_dir/.." && pwd)"
+metadata="$repo_root/macos/package.env"
+plist="$repo_root/macos/Info.plist"
+
+for script in \
+ "$script_dir/Build-MacOsPackage.sh" \
+ "$script_dir/Stage-MacOsApp.sh" \
+ "$script_dir/Test-MacOsPackage.sh" \
+ "$script_dir/Test-MacOsRuntime.sh"; do
+ bash -n "$script"
+done
+
+# shellcheck source=../macos/package.env
+source "$metadata"
+test "$PACKAGE_NAME" = devolutions-terminal
+test "$APP_ID" = com.devolutions.Terminal
+test "$EXECUTABLE_NAME" = Devolutions.Terminal
+test "$CLI_NAME" = dt
+test "$PTY_HOST_NAME" = dt-pty-host
+test "$GHOSTTY_LIBRARY" = libghostty-vt.dylib
+test "$ICON_NAME" = DevolutionsTerminal
+test "$MACOS_DEPLOYMENT_TARGET" = 13.0
+test "$LICENSE_ID" = "MIT AND OFL-1.1"
+test "$SBOM_LICENSE_ID" = "MIT AND OFL-1.1"
+test "$LICENSE_ID" = "$SBOM_LICENSE_ID"
+test "$URL_SCHEME" = dterm
+test "$BUNDLE_NAME" = "Devolutions Terminal.app"
+if grep -Eq '(^|_)VERSION=' "$metadata"; then
+ echo "macOS package metadata must not duplicate the release version." >&2
+ exit 1
+fi
+
+python3 - "$plist" "$APP_ID" "$EXECUTABLE_NAME" "$MACOS_DEPLOYMENT_TARGET" "$URL_SCHEME" <<'PY'
+import pathlib
+import sys
+import xml.etree.ElementTree as ET
+
+path, app_id, executable, minimum, scheme = sys.argv[1:]
+root = ET.parse(path).getroot().find("dict")
+children = list(root)
+keys = {}
+index = 0
+while index < len(children):
+ node = children[index]
+ if node.tag != "key":
+ index += 1
+ continue
+ value = children[index + 1]
+ if value.tag == "string":
+ keys[node.text] = value.text
+ elif value.tag == "true":
+ keys[node.text] = True
+ index += 2
+
+assert keys["CFBundleIdentifier"] == app_id
+assert keys["CFBundleExecutable"] == executable
+assert keys["CFBundlePackageType"] == "APPL"
+assert keys["LSMinimumSystemVersion"] == minimum
+assert keys["CFBundleIconFile"] == "DevolutionsTerminal"
+assert keys["NSHighResolutionCapable"] is True
+text = pathlib.Path(path).read_text(encoding="utf-8")
+assert f"{scheme}" in text
+PY
+
+if "$script_dir/Build-MacOsPackage.sh" invalid-rid >/dev/null 2>&1; then
+ echo "Builder accepted an invalid RID." >&2
+ exit 1
+fi
+echo "macOS packaging scripts and canonical metadata validation passed."
diff --git a/scripts/Test-MacOsRuntime.sh b/scripts/Test-MacOsRuntime.sh
new file mode 100755
index 0000000..b7a9beb
--- /dev/null
+++ b/scripts/Test-MacOsRuntime.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+set -euo pipefail
+export LC_ALL=C
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd -- "$script_dir/.." && pwd)"
+metadata="$repo_root/macos/package.env"
+package_dir="${1:-$repo_root/artifacts/packages}"
+
+[[ "$(uname -s)" == "Darwin" ]] ||
+ { echo "Native macOS runtime validation requires Darwin." >&2; exit 78; }
+case "$(uname -m)" in
+ arm64) rid="osx-arm64" ;;
+ x86_64) rid="osx-x64" ;;
+ *)
+ echo "Unsupported macOS architecture: $(uname -m)" >&2
+ exit 78
+ ;;
+esac
+[[ -d "$package_dir" ]] ||
+ { echo "macOS package directory not found: $package_dir" >&2; exit 66; }
+package_dir="$(cd -- "$package_dir" && pwd)"
+
+# shellcheck source=../macos/package.env
+source "$metadata"
+for command in ditto dotnet find; do
+ command -v "$command" >/dev/null 2>&1 ||
+ { echo "$command is required for native macOS runtime validation." >&2; exit 69; }
+done
+
+work="${WT_MACOS_TEST_ROOT:-$repo_root/artifacts/macos-runtime-$$}"
+rm -rf -- "$work"
+mkdir -p "$work/home"
+trap 'rm -rf -- "$work"' EXIT
+export HOME="$work/home"
+unset WT_BASE_SETTINGS_PATH DTERM_SETTINGS_PATH WT_DOTNET_SETTINGS_PATH
+
+one_artifact() {
+ local pattern="$1"
+ local matches=()
+ while IFS= read -r path; do
+ matches+=("$path")
+ done < <(find "$package_dir" -maxdepth 1 -name "$pattern" -print | sort)
+ if ((${#matches[@]} != 1)); then
+ echo "Expected exactly one $pattern in $package_dir; found ${#matches[@]}." >&2
+ exit 66
+ fi
+ printf '%s\n' "${matches[0]}"
+}
+
+zip_package="$(one_artifact "*-$rid.zip")"
+bash "$script_dir/Test-MacOsPackage.sh" "$rid" "$zip_package"
+
+extract="$work/extracted"
+mkdir -p "$extract"
+ditto -x -k "$zip_package" "$extract"
+app="$(find "$extract" -maxdepth 2 -name '*.app' -print -quit)"
+[[ -n "$app" ]] ||
+ { echo "Extracted zip has no app bundle." >&2; exit 70; }
+macos_dir="$app/Contents/MacOS"
+
+smoke_dt() {
+ local label="$1"
+ "$macos_dir/$CLI_NAME" --help | grep -F "dt - Devolutions Terminal" >/dev/null
+ local error="$work/parser-error"
+ if "$macos_dir/$CLI_NAME" --macos-invalid-option >"$error" 2>&1; then
+ echo "$label dt accepted an invalid parser option." >&2
+ exit 1
+ else
+ local status=$?
+ [[ "$status" == 2 ]] ||
+ { echo "$label dt returned $status instead of 2 for invalid input." >&2; exit 1; }
+ fi
+ grep -F "Unknown command '--macos-invalid-option'." "$error" >/dev/null
+
+ local helper_error="$work/pty-helper-error"
+ if "$macos_dir/$PTY_HOST_NAME" >"$helper_error" 2>&1; then
+ echo "$label dt-pty-host accepted missing launch arguments." >&2
+ exit 1
+ else
+ local helper_status=$?
+ [[ "$helper_status" == 64 ]] ||
+ { echo "$label dt-pty-host returned $helper_status instead of 64 for missing arguments." >&2; exit 1; }
+ fi
+ grep -F "usage: dt-pty-host" "$helper_error" >/dev/null
+ echo "NativeAOT dt startup and parser passed for $label."
+}
+
+smoke_dt "$(basename "$zip_package")"
+
+run_tests() {
+ local project="$1"
+ local filter="$2"
+ dotnet test "$repo_root/tests/$project/$project.csproj" \
+ -c Release --nologo --verbosity minimal --filter "$filter"
+}
+
+run_packaged_native_tests() {
+ local project="$1"
+ local native_name="$2"
+ local packaged_native="$3"
+ local filter="$4"
+ local project_file="$repo_root/tests/$project/$project.csproj"
+ dotnet build "$project_file" -c Release --nologo --verbosity minimal
+ local assembly
+ assembly="$(find "$repo_root/tests/$project/bin/Release" -type f \
+ -name "$project.dll" -print -quit)"
+ [[ -n "$assembly" ]] ||
+ { echo "Could not locate the $project test output." >&2; exit 70; }
+ if [[ -x "$packaged_native" ]]; then
+ install -m 0755 "$packaged_native" "$(dirname "$assembly")/$native_name"
+ else
+ install -m 0644 "$packaged_native" "$(dirname "$assembly")/$native_name"
+ fi
+ dotnet test "$project_file" -c Release --no-build --no-restore \
+ --nologo --verbosity minimal --filter "$filter"
+}
+
+run_tests Devolutions.Terminal.Cli.Tests \
+ 'FullyQualifiedName~Devolutions.Terminal.Cli.Tests.CliParserTests'
+run_tests Devolutions.Terminal.Core.Tests \
+ 'FullyQualifiedName~Devolutions.Terminal.Core.Tests.VtParserTests'
+run_packaged_native_tests Devolutions.Terminal.Ghostty.Tests libghostty-vt.dylib \
+ "$macos_dir/$GHOSTTY_LIBRARY" \
+ 'FullyQualifiedName~Devolutions.Terminal.Ghostty.Tests.GhosttyTerminalEngineTests'
+run_packaged_native_tests Devolutions.Terminal.Connection.Tests dt-pty-host \
+ "$macos_dir/$PTY_HOST_NAME" \
+ 'FullyQualifiedName~Devolutions.Terminal.Connection.Tests.LinuxPtyConnectionTests'
+run_tests Devolutions.Terminal.Broker.Tests \
+ 'FullyQualifiedName~Devolutions.Terminal.Broker.Tests.BrokerTests.ConcurrentClientsAreServedBySinglePrimary'
+run_tests Devolutions.Terminal.Settings.Tests \
+ 'FullyQualifiedName~Devolutions.Terminal.Settings.Tests.DynamicProfileGeneratorTests.MacOsShellsUseMacOsSourceAndZsh|FullyQualifiedName~Devolutions.Terminal.Settings.Tests.LinuxRuntimeEnvironmentTests'
+
+echo "Native macOS non-UI runtime validation passed on $(uname -m)."
diff --git a/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs b/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs
index 363e717..dd7c8a7 100644
--- a/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs
+++ b/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs
@@ -144,7 +144,7 @@ public string GetCapabilityReport()
"System notifications: available through osascript display notification\n" +
"Global summon hotkeys: unsupported; use the broker or dt -w\n" +
"Unix PTY: dt-pty-host (forkpty)\n" +
- "Ghostty engine: not bundled yet",
+ GetMacOsGhosttyCapability(),
_ => "Desktop platform: unsupported\nOpen and notification integrations are unavailable.",
};
}
@@ -161,6 +161,14 @@ private string GetWindowsCapabilityReport()
$"Default terminal: {defaultTerminal.Status} ({defaultTerminal.Diagnostic})";
}
+ private static string GetMacOsGhosttyCapability()
+ {
+ var library = Path.Combine(AppContext.BaseDirectory, "libghostty-vt.dylib");
+ return File.Exists(library)
+ ? "Ghostty engine: available (libghostty-vt.dylib)"
+ : "Ghostty engine: not restored for this build";
+ }
+
private static string Availability(
ShellIntegrationResult result,
ShellIntegrationCapability capability) =>
diff --git a/src/Devolutions.Terminal.App/Platform/WindowChrome.cs b/src/Devolutions.Terminal.App/Platform/WindowChrome.cs
index 013f829..3ba0a42 100644
--- a/src/Devolutions.Terminal.App/Platform/WindowChrome.cs
+++ b/src/Devolutions.Terminal.App/Platform/WindowChrome.cs
@@ -1,9 +1,13 @@
+using Avalonia;
using Devolutions.Terminal.Settings;
namespace Devolutions.Terminal.App.Platform;
public static class WindowChrome
{
+ public const double MacOsTrafficLightFallback = 70;
+ public const double WindowsCaptionFallback = 138;
+
public static bool ShouldShowTabRow(
AppSettings settings,
int tabCount,
@@ -18,9 +22,57 @@ public static bool ShouldShowTabRow(
return settings.AlwaysShowTabs || tabCount > 1;
}
- public static bool ShouldUseCustomTitlebar(AppSettings settings, bool embedded)
+ public static bool ShouldUseCustomTitlebar(AppSettings settings, bool embedded, bool macOS = false)
{
ArgumentNullException.ThrowIfNull(settings);
return !embedded && settings.ShowTabsInTitlebar;
}
+
+ public static Thickness TitleBarContentMargin(
+ bool fullscreen,
+ bool macOS,
+ bool windows,
+ Thickness offScreenMargin,
+ bool rightToLeft = false)
+ {
+ if (fullscreen)
+ {
+ return new Thickness(8, 0, 8, 0);
+ }
+
+ if (macOS && !MacOsWindowControlsOnRight(offScreenMargin, rightToLeft))
+ {
+ return new Thickness(
+ Math.Max(offScreenMargin.Left, MacOsTrafficLightFallback),
+ 0,
+ 8,
+ 0);
+ }
+
+ var right = windows || macOS
+ ? Math.Max(offScreenMargin.Right, WindowsCaptionFallback)
+ : WindowsCaptionFallback;
+ return new Thickness(8, 0, right, 0);
+ }
+
+ public static bool MacOsWindowControlsOnRight(
+ Thickness decorationMargin,
+ bool rightToLeft)
+ {
+ if (rightToLeft)
+ {
+ return true;
+ }
+
+ return decorationMargin.Right - decorationMargin.Left >= 24 &&
+ decorationMargin.Right >= 40;
+ }
+
+ public static double TabStripTrailingReserve(
+ bool macOS,
+ bool windows,
+ bool macOsControlsOnRight = false) =>
+ macOS && !macOsControlsOnRight
+ ? MacOsTrafficLightFallback + 72
+ : 253;
}
diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml b/src/Devolutions.Terminal.App/Views/MainWindow.axaml
index 42d4b39..f581569 100644
--- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml
+++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml
@@ -1,5 +1,6 @@
-
-
-
-
+ ColumnDefinitions="Auto,Auto,*"
+ Margin="8,0,8,0">
+
+
+
+
+
-
diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs
index 8db8398..c23e61b 100644
--- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs
+++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs
@@ -23,6 +23,7 @@
using Devolutions.Terminal.App.Platform;
using Devolutions.Terminal.App.Routing;
using Devolutions.Terminal.Settings.Editor;
+using System.Globalization;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -149,7 +150,10 @@ public MainWindow(
Dispatcher.UIThread.Post(CaptureNormalWindowBounds, DispatcherPriority.Background);
PropertyChanged += (_, args) =>
{
- if (args.Property == WindowStateProperty)
+ if (args.Property == WindowStateProperty ||
+ args.Property == OffScreenMarginProperty ||
+ args.Property == WindowDecorationMarginProperty ||
+ args.Property == FlowDirectionProperty)
{
UpdateFullscreenChrome();
}
@@ -954,6 +958,9 @@ private void RebuildTabs()
ContextMenu = CreateTabContextMenu(tab),
Width = tabWidth,
};
+ Avalonia.Controls.Chrome.WindowDecorationProperties.SetElementRole(
+ button,
+ Avalonia.Input.WindowDecorationsElementRole.User);
if (TryParseColor(tab.Color ?? presentation.Color, out var tabColor))
{
button.Background = new SolidColorBrush(tabColor);
@@ -1209,11 +1216,24 @@ private Button CreateCloseButton(TerminalTab tab)
var close = new Button
{
Classes = { "icon" },
- Content = "×",
Width = 22,
Height = 22,
- FontSize = 14,
+ Padding = new Thickness(0),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Content = new Avalonia.Controls.Shapes.Path
+ {
+ Data = Geometry.Parse("M 0,0 L 8,8 M 8,0 L 0,8"),
+ Stroke = Brushes.White,
+ StrokeThickness = 1.2,
+ Width = 8,
+ Height = 8,
+ Stretch = Stretch.Uniform,
+ },
};
+ Avalonia.Controls.Chrome.WindowDecorationProperties.SetElementRole(
+ close,
+ Avalonia.Input.WindowDecorationsElementRole.User);
close.Click += async (_, e) =>
{
e.Handled = true;
@@ -2644,12 +2664,26 @@ private void UpdateFullscreenChrome()
{
var isFullscreen = WindowState == WindowState.FullScreen;
ExitFullscreenButton.IsVisible = isFullscreen && !Win32ParentWindow.IsRequested;
- TitleBarLayout.Margin = isFullscreen
- ? new Thickness(8, 0, 8, 0)
- : new Thickness(8, 0, 138, 0);
+ ApplyTitleBarMargin();
ApplyWindowChrome();
}
+ private void ApplyTitleBarMargin() =>
+ TitleBarLayout.Margin = WindowChrome.TitleBarContentMargin(
+ fullscreen: WindowState == WindowState.FullScreen,
+ macOS: OperatingSystem.IsMacOS(),
+ windows: OperatingSystem.IsWindows(),
+ offScreenMargin: MacOsDecorationMargin(),
+ rightToLeft: FlowDirection == FlowDirection.RightToLeft ||
+ CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft);
+
+ private Thickness MacOsDecorationMargin() =>
+ new(
+ Math.Max(OffScreenMargin.Left, WindowDecorationMargin.Left),
+ 0,
+ Math.Max(OffScreenMargin.Right, WindowDecorationMargin.Right),
+ 0);
+
private void ApplyWindowChrome()
{
var embedded = OperatingSystem.IsWindows() && Win32ParentWindow.IsRequested;
@@ -2659,6 +2693,7 @@ private void ApplyWindowChrome()
TabScrollViewer.IsVisible = showTabs;
NewTabButton.IsVisible = showTabs;
MenuButton.IsVisible = showTabs;
+ ApplyTitleBarMargin();
if (embedded)
{
@@ -2671,6 +2706,11 @@ private void ApplyWindowChrome()
var customTitlebar = WindowChrome.ShouldUseCustomTitlebar(_settings, embedded: false);
ExtendClientAreaToDecorationsHint = customTitlebar && !_focusMode;
TitleBar.IsVisible = !_focusMode && (showTabs || customTitlebar);
+ TitleBarLayout.ColumnDefinitions = new ColumnDefinitions("Auto,Auto,*");
+ TabScrollViewer.HorizontalAlignment = HorizontalAlignment.Left;
+ NewTabCluster.HorizontalAlignment = HorizontalAlignment.Left;
+ TitleBar.Height = 40;
+ NewTabCluster.Height = double.NaN;
}
private async Task SummonAsync(GlobalSummonArgs args, bool quake)
@@ -2842,7 +2882,14 @@ private void MainWindow_OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
Dispatcher.UIThread.Post(CaptureNormalWindowBounds, DispatcherPriority.Background);
- TabScrollViewer.MaxWidth = Math.Max(120, e.NewSize.Width - 253);
+ var reserved = WindowChrome.TabStripTrailingReserve(
+ OperatingSystem.IsMacOS(),
+ OperatingSystem.IsWindows(),
+ WindowChrome.MacOsWindowControlsOnRight(
+ MacOsDecorationMargin(),
+ FlowDirection == FlowDirection.RightToLeft ||
+ CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft));
+ TabScrollViewer.MaxWidth = Math.Max(120, e.NewSize.Width - reserved);
RebuildTabs();
}
diff --git a/src/Devolutions.Terminal.Broker/BrokerHost.cs b/src/Devolutions.Terminal.Broker/BrokerHost.cs
index 95010b4..291c516 100644
--- a/src/Devolutions.Terminal.Broker/BrokerHost.cs
+++ b/src/Devolutions.Terminal.Broker/BrokerHost.cs
@@ -58,7 +58,7 @@ private BrokerHost(
var store = new BrokerEndpointStore(endpointDirectory, instanceKey);
var endpoint = new BrokerEndpoint(
BrokerProtocol.Version,
- $"Devolutions.Terminal.DotNet.v{BrokerProtocol.Version}.{RandomNumberGenerator.GetHexString(16)}",
+ $"dt.v{BrokerProtocol.Version}.{RandomNumberGenerator.GetHexString(16)}",
RandomNumberGenerator.GetHexString(32),
Environment.ProcessId);
return new BrokerHost(store, handler, election, endpoint);
diff --git a/src/Devolutions.Terminal.Cli/Program.cs b/src/Devolutions.Terminal.Cli/Program.cs
index 673b221..7dbb70f 100644
--- a/src/Devolutions.Terminal.Cli/Program.cs
+++ b/src/Devolutions.Terminal.Cli/Program.cs
@@ -49,7 +49,9 @@ private static bool RequiresExistingWindow(string target) =>
private static bool TryLaunchHost(IReadOnlyList args)
{
- var hostPath = Path.Combine(AppContext.BaseDirectory, "Devolutions.Terminal.exe");
+ var hostPath = Path.Combine(
+ AppContext.BaseDirectory,
+ OperatingSystem.IsWindows() ? "Devolutions.Terminal.exe" : "Devolutions.Terminal");
if (!File.Exists(hostPath))
{
return false;
diff --git a/src/Devolutions.Terminal.Connection/AzureCloudShellConnection.cs b/src/Devolutions.Terminal.Connection/AzureCloudShellConnection.cs
index f1bfae2..ee05b2a 100644
--- a/src/Devolutions.Terminal.Connection/AzureCloudShellConnection.cs
+++ b/src/Devolutions.Terminal.Connection/AzureCloudShellConnection.cs
@@ -562,8 +562,21 @@ await session.Socket.CloseOutputAsync(
}
finally
{
- await session.Lifetime.CancelAsync().ConfigureAwait(false);
- session.Socket.Abort();
+ try
+ {
+ await session.Lifetime.CancelAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ try
+ {
+ session.Socket.Abort();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
}
if (session.ReceiveTask is not null)
diff --git a/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs b/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs
index b1f5a8c..e158331 100644
--- a/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs
+++ b/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs
@@ -599,9 +599,9 @@ private static void ValidateOptions(
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.CommandLine);
cancellationToken.ThrowIfCancellationRequested();
- if (!OperatingSystem.IsLinux())
+ if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
{
- throw new PlatformNotSupportedException("Linux PTY requires Linux.");
+ throw new PlatformNotSupportedException("Unix PTY requires Linux or macOS.");
}
if (options.Columns is < 1 or > ushort.MaxValue)
diff --git a/src/Devolutions.Terminal.Control/TermControl.cs b/src/Devolutions.Terminal.Control/TermControl.cs
index c631831..245c4ae 100644
--- a/src/Devolutions.Terminal.Control/TermControl.cs
+++ b/src/Devolutions.Terminal.Control/TermControl.cs
@@ -36,8 +36,6 @@ public sealed class TermControl : Avalonia.Controls.Control
DataFormat.CreateBytesPlatformFormat("Rich Text Format");
private readonly DispatcherTimer _blinkTimer;
private readonly object _outputLock = new();
- private readonly MemoryStream _pendingOutput = new();
- private bool _outputDrainScheduled;
private bool _acceptOutput;
private readonly SkiaTerminalRenderer _renderer = new();
private readonly TerminalSearchSession _search;
@@ -106,13 +104,34 @@ public TermControl(ITerminalEngine? engine = null)
SetSelection(null);
}
- _textInputMethodClient.NotifyCursorChanged();
AccessibilityTextChanged?.Invoke(this, EventArgs.Empty);
ScrollMarksChanged?.Invoke(this, EventArgs.Empty);
ViewportChanged?.Invoke(this, EventArgs.Empty);
- Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ _textInputMethodClient.NotifyCursorChanged();
+ InvalidateVisual();
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ _textInputMethodClient.NotifyCursorChanged();
+ InvalidateVisual();
+ }, DispatcherPriority.Render);
+ }
+ };
+ Engine.TitleChanged += (_, title) =>
+ {
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ TitleChanged?.Invoke(this, title);
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(() => TitleChanged?.Invoke(this, title));
+ }
};
- Engine.TitleChanged += (_, title) => TitleChanged?.Invoke(this, title);
Engine.ResponseReady += (_, data) => _connection?.Write(data);
Engine.ClipboardWriteRequested += (_, text) =>
Dispatcher.UIThread.Post(() => SetClipboardFromTerminalObservedAsync(text));
@@ -241,8 +260,6 @@ await connection.StartAsync(
lock (_outputLock)
{
_acceptOutput = false;
- _pendingOutput.SetLength(0);
- _outputDrainScheduled = false;
}
await connection.DisposeAsync().ConfigureAwait(false);
throw;
@@ -296,8 +313,6 @@ public async Task CloseAsync()
lock (_outputLock)
{
_acceptOutput = false;
- _pendingOutput.SetLength(0);
- _outputDrainScheduled = false;
}
await connection.DisposeAsync().ConfigureAwait(false);
@@ -798,12 +813,6 @@ public bool Find(string query, bool previous = false)
public void ResetTerminal()
{
- lock (_outputLock)
- {
- _pendingOutput.SetLength(0);
- _outputDrainScheduled = false;
- }
-
Engine.Reset();
SetSelection(null);
_isMarkMode = false;
@@ -875,13 +884,18 @@ public override void Render(DrawingContext context)
return;
}
- var scale = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1;
- _renderer.Resize(new RenderViewport(Engine.Columns, Engine.Rows, scale));
- MeasureGlyph();
- ResizeEngine(Engine.Columns, Engine.Rows);
+ TerminalSnapshot snapshot;
+ lock (_outputLock)
+ {
+ var scale = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1;
+ _renderer.Resize(new RenderViewport(Engine.Columns, Engine.Rows, scale));
+ MeasureGlyph();
+ ResizeEngine(Engine.Columns, Engine.Rows);
+ snapshot = Engine.CreateSnapshot();
+ }
var profile = Profile;
var frame = TerminalRenderPlanner.Create(
- Engine.CreateSnapshot(),
+ snapshot,
Engine.Scheme,
new TerminalRenderOptions
{
@@ -1232,7 +1246,6 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
private void OnOutput(object? sender, ReadOnlyMemory data)
{
- var scheduleDrain = false;
lock (_outputLock)
{
if (!_acceptOutput)
@@ -1240,17 +1253,9 @@ private void OnOutput(object? sender, ReadOnlyMemory data)
return;
}
- _pendingOutput.Write(data.Span);
- if (!_outputDrainScheduled)
- {
- _outputDrainScheduled = true;
- scheduleDrain = true;
- }
- }
-
- if (scheduleDrain)
- {
- Dispatcher.UIThread.Post(DrainOutput, DispatcherPriority.Render);
+ // Feed on the PTY thread so cursor-position reports (CSI 6n) go
+ // back to zsh before PROMPT_SP prints a spurious '%'.
+ Engine.Feed(data.Span);
}
}
@@ -1271,25 +1276,6 @@ private void OnSessionExited(object? sender, TerminalExitInfo exit)
});
}
- private void DrainOutput()
- {
- byte[] chunk;
- lock (_outputLock)
- {
- if (_pendingOutput.Length == 0)
- {
- _outputDrainScheduled = false;
- return;
- }
-
- chunk = _pendingOutput.ToArray();
- _pendingOutput.SetLength(0);
- _outputDrainScheduled = false;
- }
-
- Engine.Feed(chunk);
- }
-
private (int X, int Y) HitTest(Point point)
{
const double padding = 8;
diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1
index b35b3ec..1243129 100644
--- a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1
+++ b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1
@@ -131,17 +131,11 @@ begin {
Assert-Condition ($identity.Name -eq "Devolutions.Terminal") "Unexpected package identity name."
Assert-Condition ($identity.Publisher -eq "CN=Devolutions Inc.") "Unexpected package publisher."
Assert-Condition ($identity.ProcessorArchitecture -in @("x64", "arm64")) "Unexpected package architecture '$($identity.ProcessorArchitecture)'."
- $expectedGhosttyHash = if ($identity.ProcessorArchitecture -eq "arm64") {
- "691A331E92D0CE17B8407DD370D26394090B14AB8A7C398DF497442293D4ED72"
- }
- else {
- "DCB3274F9D8C945AC765A11903614C5DA4BC0CC2EF4EBC23E8CD70C130B7B458"
- }
- $actualGhosttyHash = (Get-FileHash -LiteralPath $ghosttyPath -Algorithm SHA256).Hash
- Assert-Condition (
- $actualGhosttyHash -eq $expectedGhosttyHash
- ) "ghostty-vt.dll architecture/hash mismatch in '$Path'."
$expectedMachine = if ($identity.ProcessorArchitecture -eq "arm64") { 0xAA64 } else { 0x8664 }
+ $ghosttyMachine = Get-PeMachine $ghosttyPath
+ Assert-Condition (
+ $ghosttyMachine -eq $expectedMachine
+ ) "ghostty-vt.dll architecture 0x$($ghosttyMachine.ToString('X4')) does not match '$($identity.ProcessorArchitecture)' in '$Path'."
foreach ($helper in @("Devolutions.Terminal.ShellExt.dll", "dt-shell-integration.exe")) {
$helperPath = Join-Path $extractPath $helper
Assert-Condition (Test-Path -LiteralPath $helperPath -PathType Leaf) "'$helper' is missing from '$Path'."
diff --git a/src/Devolutions.Terminal/App.axaml b/src/Devolutions.Terminal/App.axaml
index c3438dd..5dcd340 100644
--- a/src/Devolutions.Terminal/App.axaml
+++ b/src/Devolutions.Terminal/App.axaml
@@ -38,6 +38,22 @@
+
+
+