From 097c3d3956580f6d156d2da23c7b2d780768b69c Mon Sep 17 00:00:00 2001 From: Marc-Andre Moreau Date: Thu, 3 Sep 2026 17:15:54 -0400 Subject: [PATCH 1/7] Add macOS NativeAOT host, packaging, and title-bar inset. Ship a working osx-arm64/osx-x64 port: restore Ghostty and dt-pty-host, publish a signed .app, and run managed plus PTY/Ghostty gates on Darwin. Keep the Windows tab chrome with a macOS-only snug left inset for traffic lights; skip the inset when controls are on the right. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-terminal.yml | 90 ++++++++++++ CONTRIBUTING.md | 15 +- README.md | 12 +- docs/macos.md | 30 ++-- docs/parity-status.md | 10 +- docs/release.md | 24 +++- macos/Info.plist | 4 + macos/package.env | 15 ++ native/NativeLibraries.targets | 6 + native/Restore-NativeLibraries.ps1 | 24 ++-- native/linux-pty/Build-LinuxPtyHost.ps1 | 34 +++-- scripts/Build-MacOsPackage.sh | 98 +++++++++++++ scripts/Stage-MacOsApp.sh | 118 +++++++++++++++ scripts/Test-MacOsPackage.sh | 120 ++++++++++++++++ scripts/Test-MacOsPackagingMetadata.sh | 73 ++++++++++ scripts/Test-MacOsRuntime.sh | 134 ++++++++++++++++++ .../Platform/PlatformLauncher.cs | 10 +- .../Platform/WindowChrome.cs | 54 ++++++- .../Views/MainWindow.axaml | 84 ++++++----- .../Views/MainWindow.axaml.cs | 61 +++++++- src/Devolutions.Terminal.Broker/BrokerHost.cs | 2 +- src/Devolutions.Terminal.Cli/Program.cs | 4 +- .../LinuxPtyConnection.cs | 4 +- .../TermControl.cs | 87 ++++-------- src/Devolutions.Terminal/App.axaml | 18 +++ .../Devolutions.Terminal.App.Tests.csproj | 3 + .../MacOsBundleMetadataTests.cs | 75 ++++++++++ .../PlatformLauncherTests.cs | 3 + .../WindowChromeTests.cs | 83 +++++++++++ .../LinuxPtyConnectionTests.cs | 7 +- .../LinuxRuntimeEnvironmentTests.cs | 20 +++ 31 files changed, 1168 insertions(+), 154 deletions(-) create mode 100644 macos/package.env create mode 100755 scripts/Build-MacOsPackage.sh create mode 100755 scripts/Stage-MacOsApp.sh create mode 100755 scripts/Test-MacOsPackage.sh create mode 100755 scripts/Test-MacOsPackagingMetadata.sh create mode 100755 scripts/Test-MacOsRuntime.sh create mode 100644 tests/Devolutions.Terminal.App.Tests/MacOsBundleMetadataTests.cs diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index 654004f..97c02d7 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 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..b5008a0 100644 --- a/native/NativeLibraries.targets +++ b/native/NativeLibraries.targets @@ -35,6 +35,9 @@ DestinationFiles="$(TargetDir)dt-pty-host" SkipUnchangedFiles="true" Condition="'$(NativeRestore)' != 'ghostty' and '$(UnixPtyRid)' != '' and Exists('$(NativeRoot)linux-pty\$(UnixPtyRid)\dt-pty-host')" /> + + diff --git a/native/Restore-NativeLibraries.ps1 b/native/Restore-NativeLibraries.ps1 index db85501..ee96edf 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 $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 $nativeRoot "linux-pty" "Build-LinuxPtyHost.ps1") @ptyArgs if ($LASTEXITCODE -ne 0) { throw "Build-LinuxPtyHost.ps1 failed for $Rid." } 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/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..f14b213 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; @@ -99,20 +97,24 @@ public TermControl(ITerminalEngine? engine = null) Engine.Invalidated += (_, _) => { - if (_selection is not null && - (_selectionCoordinateVersion != Engine.Buffer.CoordinateVersion || - _selectionAlternateBuffer != Engine.AlternateBufferActive)) + Dispatcher.UIThread.Post(() => { - SetSelection(null); - } + if (_selection is not null && + (_selectionCoordinateVersion != Engine.Buffer.CoordinateVersion || + _selectionAlternateBuffer != Engine.AlternateBufferActive)) + { + 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); + _textInputMethodClient.NotifyCursorChanged(); + AccessibilityTextChanged?.Invoke(this, EventArgs.Empty); + ScrollMarksChanged?.Invoke(this, EventArgs.Empty); + ViewportChanged?.Invoke(this, EventArgs.Empty); + InvalidateVisual(); + }, DispatcherPriority.Render); }; - Engine.TitleChanged += (_, title) => TitleChanged?.Invoke(this, title); + Engine.TitleChanged += (_, title) => + Dispatcher.UIThread.Post(() => TitleChanged?.Invoke(this, title)); Engine.ResponseReady += (_, data) => _connection?.Write(data); Engine.ClipboardWriteRequested += (_, text) => Dispatcher.UIThread.Post(() => SetClipboardFromTerminalObservedAsync(text)); @@ -241,8 +243,6 @@ await connection.StartAsync( lock (_outputLock) { _acceptOutput = false; - _pendingOutput.SetLength(0); - _outputDrainScheduled = false; } await connection.DisposeAsync().ConfigureAwait(false); throw; @@ -296,8 +296,6 @@ public async Task CloseAsync() lock (_outputLock) { _acceptOutput = false; - _pendingOutput.SetLength(0); - _outputDrainScheduled = false; } await connection.DisposeAsync().ConfigureAwait(false); @@ -798,12 +796,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 +867,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 +1229,6 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e) private void OnOutput(object? sender, ReadOnlyMemory data) { - var scheduleDrain = false; lock (_outputLock) { if (!_acceptOutput) @@ -1240,17 +1236,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 +1259,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/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 @@ + + +