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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,33 @@ jobs:
with:
name: ttyd.${{ matrix.target }}
path: build/ttyd*

macos:
name: macos (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14 # Apple Silicon
arch: arm64
asset: aarch64-apple-darwin
- runner: macos-13 # Intel
arch: x86_64
asset: x86_64-apple-darwin
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
- name: Install CMake
# CMake is a build-time tool only -- never linked into the binary. It
# comes from the runner's preinstalled Homebrew. (pkg-config isn't
# needed; ttyd finds its deps via CMake find_package/find_library.)
run: brew install cmake
- name: Native build (${{ matrix.arch }})
env:
BUILD_TARGET: ${{ matrix.arch }}
run: ./scripts/macos-build.sh
# Same upload shape as the `cross` job: artifact name carries the per-arch
# target, and release.yml's publish step derives the asset name from it.
- uses: actions/upload-artifact@v7
with:
name: ttyd.${{ matrix.asset }}
path: build/ttyd*
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ ttyd is a simple command-line tool for sharing terminal over the web.

- Install with [Homebrew](http://brew.sh): `brew install ttyd`
- Install with [MacPorts](https://www.macports.org): `sudo port install ttyd`
- Precompiled binaries: download from the [releases](https://github.com/tsl0922/ttyd/releases) page (note: not codesigned/notarized — see [Gatekeeper and quarantine](docs/macos-build.md#gatekeeper-and-quarantine-on-downloaded-release-binaries))
- [Build from source](docs/macos-build.md)

## Install on Linux

Expand Down
119 changes: 119 additions & 0 deletions docs/macos-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Build ttyd from source on macOS

This guide builds a self-contained `ttyd` binary natively on macOS. The
third-party dependencies json-c, OpenSSL, libuv and libwebsockets are linked
statically; zlib is provided by the macOS system `libz` and, together with
`libSystem` and `libutil`, stays dynamically linked. That is normal and
unavoidable on macOS, which ships no static `libSystem`.

You do **not** need Homebrew for the resulting binary. Homebrew (or any other
source) is only used to obtain the build-time tool CMake; it is not linked into
`ttyd`. (For a fully Homebrew-free recipe that also fetches CMake itself, see the
standalone gist linked from the issue tracker.)

> Prefer a package manager? `brew install ttyd` and `sudo port install ttyd`
> remain the easiest options. This guide is for building from source or
> producing your own static binary.

## Prerequisites

- macOS 11 or newer (Apple Silicon or Intel)
- Xcode Command Line Tools: `xcode-select --install`
- CMake. Easiest via Homebrew:

```bash
brew install cmake
```

(CMake is a build-time tool only and is not linked into `ttyd`; ttyd finds its
dependencies via CMake's own `find_package`/`find_library`, so `pkg-config` is
not required. libuv builds with its own CMake build here, so no
autoconf/automake/libtool are required.)

## Build

Build on a Mac of the architecture you are targeting (the project's CI builds
arm64 on `macos-14` and x86_64 on `macos-13`). From a clone of this repository:

```bash
# arm64 (Apple Silicon)
env BUILD_TARGET=arm64 ./scripts/macos-build.sh

# x86_64 (Intel)
env BUILD_TARGET=x86_64 ./scripts/macos-build.sh
```

`BUILD_TARGET` defaults to your machine's native architecture (`uname -m`) if
unset. The dependency versions match `scripts/cross-build.sh`:

| dependency | version |
|---|---|
| json-c | 0.18 |
| OpenSSL | 3.6.1 |
| libuv | 1.52.1 |
| libwebsockets | 4.5.7 |

(zlib is the macOS system `libz`, not built here — the one dependency that differs from `cross-build.sh`.)

The finished binary is written to `build/ttyd`.

## Verify

```bash
./build/ttyd --version
otool -L ./build/ttyd
```

A correct build links only macOS system libraries. Expected `otool -L` output
(verified on macOS arm64 with ttyd 1.7.7):

```
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices
/System/Library/Frameworks/IOKit.framework/Versions/A/IOKit
/usr/lib/libz.1.dylib
/usr/lib/libutil.dylib
/usr/lib/libSystem.B.dylib
```

OpenSSL, libwebsockets, libuv and json-c do not appear — they are statically
linked. `CoreFoundation`, `CoreServices` and `IOKit` are required by libuv. The
dynamic `libz`, `libutil` and `libSystem` links are expected macOS behavior, not
a problem.

## How it differs from the Linux cross build

`scripts/macos-build.sh` is the macOS companion to `scripts/cross-build.sh`. It
uses the same dependency versions and the same libwebsockets feature flags; only
the platform-specific pieces differ:

| Concern | Linux (`cross-build.sh`) | macOS (`macos-build.sh`) |
|---|---|---|
| Toolchain | musl cross compiler | native clang |
| Per-arch select | `CHOST` / CMake cross file | `CMAKE_OSX_ARCHITECTURES` + OpenSSL `darwin64-*-cc` |
| Final link | `-static ...` | `-Wl,-dead_strip` + CoreFoundation/CoreServices/IOKit frameworks (no `-static`) |
| Strip | `-Wl,-s` at link | `strip -S` afterward |
| `sed -i` | GNU form | BSD form (`-i ''`) |

## Gatekeeper and quarantine on downloaded release binaries

> **The macOS binaries attached to GitHub Releases are not codesigned or
> notarized.** macOS Gatekeeper quarantines any executable downloaded from the
> internet that lacks a Developer ID signature and a notarization ticket. After
> downloading `ttyd.aarch64-apple-darwin` or `ttyd.x86_64-apple-darwin` you will
> likely see *"ttyd cannot be opened because the developer cannot be verified"*
> or *"is damaged and can't be opened"*. Clear the quarantine attribute to run
> it:
>
> ```bash
> xattr -d com.apple.quarantine ./ttyd.aarch64-apple-darwin
> ```
>
> (Or right-click the binary in Finder and choose **Open** once.)
>
> Binaries you build yourself with this guide are **not** quarantined and need
> no such step.
>
> Removing this friction would require codesigning + notarization with a paid
> Apple Developer ID, which only the project maintainer can set up. It is an
> open question, not something these instructions claim to provide.
166 changes: 166 additions & 0 deletions scripts/macos-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/bin/bash
#
# Native macOS static build of ttyd (TLS via OpenSSL).
#
# macOS companion to scripts/cross-build.sh: it pins the SAME dependency versions
# and uses the SAME libwebsockets feature flags, but builds natively per-arch (no
# cross toolchain). The third-party deps (json-c, OpenSSL, libuv, libwebsockets)
# are linked statically. zlib resolves to the macOS system libz, and libSystem /
# libutil stay dynamically linked -- normal and unavoidable on macOS, which ships
# no static libSystem.
#
# Build on a Mac of the matching architecture (the CI matrix does this: arm64 on
# macos-14, x86_64 on macos-13). Single-arch native build; cross-arch from one
# host is not supported (dependency configure steps run native test binaries).
#
# Example:
# env BUILD_TARGET=arm64 ./scripts/macos-build.sh
#
set -eo pipefail

BUILD_TARGET="${BUILD_TARGET:-$(uname -m)}" # arm64 | x86_64
case "${BUILD_TARGET}" in
arm64|aarch64) ARCH="arm64"; OPENSSL_TARGET="darwin64-arm64-cc" ;;
x86_64) ARCH="x86_64"; OPENSSL_TARGET="darwin64-x86_64-cc" ;;
*) echo "unknown macOS target: ${BUILD_TARGET}" >&2 && exit 1 ;;
esac

ROOT="${ROOT:-$(pwd)}"
BUILD_DIR="${BUILD_DIR:-${ROOT}/.macos-build/${ARCH}}" # unpacked dep sources
STAGE_DIR="${STAGE_DIR:-${ROOT}/.macos-stage/${ARCH}}" # static install prefix

JSON_C_VERSION="${JSON_C_VERSION:-0.18}"
OPENSSL_VERSION="${OPENSSL_VERSION:-3.6.1}"
LIBUV_VERSION="${LIBUV_VERSION:-1.52.1}"
LIBWEBSOCKETS_VERSION="${LIBWEBSOCKETS_VERSION:-4.5.7}"

NPROC="$(sysctl -n hw.ncpu)"
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}"

rm -rf "${BUILD_DIR}" "${STAGE_DIR}"
mkdir -p "${BUILD_DIR}" "${STAGE_DIR}"

build_jsonc() {
echo "=== Building json-c-${JSON_C_VERSION} (${ARCH})..."
curl -fSsLo- "https://s3.amazonaws.com/json-c_releases/releases/json-c-${JSON_C_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/json-c-${JSON_C_VERSION}" >/dev/null
rm -rf build && mkdir -p build && cd build
# NB: cross-build.sh passes -DDISABLE_THREAD_LOCAL_STORAGE=ON for musl;
# on macOS clang that breaks json-c (undefined SPEC___THREAD), so it is
# intentionally omitted -- clang supports thread-local storage natively.
cmake -DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_OSX_ARCHITECTURES="${ARCH}" \
-DCMAKE_INSTALL_PREFIX="${STAGE_DIR}" \
-DBUILD_SHARED_LIBS=OFF \
-DBUILD_TESTING=OFF \
..
make -j"${NPROC}" install
popd >/dev/null
}

build_openssl() {
echo "=== Building openssl-${OPENSSL_VERSION} (${OPENSSL_TARGET})..."
curl -fSsLo- "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/openssl-${OPENSSL_VERSION}" >/dev/null
# darwin64-<arch>-cc selects the arch; native build, so no CROSS_COMPILE.
./Configure "${OPENSSL_TARGET}" no-shared no-tests no-ssl3 no-err \
-DOPENSSL_SMALL_FOOTPRINT --prefix="${STAGE_DIR}"
make -j"${NPROC}" >/dev/null
make install_sw # libs + headers only; skip docs/man pages
popd >/dev/null
}

build_libuv() {
echo "=== Building libuv-${LIBUV_VERSION} (${ARCH})..."
curl -fSsLo- "https://dist.libuv.org/dist/v${LIBUV_VERSION}/libuv-v${LIBUV_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/libuv-v${LIBUV_VERSION}" >/dev/null
# libuv ships a CMake build; use it (no autoconf/automake/libtool needed).
rm -rf build && mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_OSX_ARCHITECTURES="${ARCH}" \
-DCMAKE_INSTALL_PREFIX="${STAGE_DIR}" \
-DBUILD_TESTING=OFF \
-DLIBUV_BUILD_SHARED=OFF \
..
make -j"${NPROC}" install
popd >/dev/null
}

build_libwebsockets() {
echo "=== Building libwebsockets-${LIBWEBSOCKETS_VERSION} (${ARCH})..."
curl -fSsLo- "https://github.com/warmcat/libwebsockets/archive/v${LIBWEBSOCKETS_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/libwebsockets-${LIBWEBSOCKETS_VERSION}" >/dev/null
# Same patch cross-build.sh applies, with BSD sed: macOS sed REQUIRES an
# explicit (empty) backup-suffix arg after -i, or it eats the next token.
sed -i '' 's/ websockets_shared//g' cmake/libwebsockets-config.cmake.in
rm -rf build && mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_OSX_ARCHITECTURES="${ARCH}" \
-DCMAKE_INSTALL_PREFIX="${STAGE_DIR}" \
-DCMAKE_PREFIX_PATH="${STAGE_DIR}" \
-DOPENSSL_ROOT_DIR="${STAGE_DIR}" \
-DOPENSSL_USE_STATIC_LIBS=TRUE \
-DLIBUV_INCLUDE_DIRS="${STAGE_DIR}/include" \
-DLIBUV_LIBRARIES="${STAGE_DIR}/lib/libuv.a" \
-DLWS_WITH_SSL=ON \
-DLWS_WITH_LIBUV=ON \
-DLWS_STATIC_PIC=ON \
-DLWS_WITH_SHARED=OFF \
-DLWS_WITHOUT_TESTAPPS=ON \
-DLWS_WITHOUT_CLIENT=ON \
-DLWS_WITH_HTTP2=ON \
-DLWS_WITH_HTTP_STREAM_COMPRESSION=ON \
-DLWS_UNIX_SOCK=ON \
-DLWS_IPV6=ON \
-DLWS_WITHOUT_EXTENSIONS=OFF \
-DLWS_ROLE_RAW_FILE=OFF \
-DLWS_WITH_HTTP_BASIC_AUTH=OFF \
-DLWS_WITH_UDP=OFF \
-DLWS_WITH_LEJP=OFF \
-DLWS_WITH_LEJP_CONF=OFF \
-DLWS_WITH_LWSAC=OFF \
-DLWS_WITH_SEQUENCER=OFF \
-DLWS_WITH_UPNG=OFF \
-DLWS_WITH_JPEG=OFF \
-DLWS_WITH_DLO=OFF \
-DLWS_WITH_SYS_STATE=OFF \
-DLWS_WITH_SYS_SMD=OFF \
-DLWS_WITH_SECURE_STREAMS=OFF \
-DLWS_CTEST_INTERNET_AVAILABLE=OFF \
..
make -j"${NPROC}" install
popd >/dev/null
}

build_ttyd() {
echo "=== Building ttyd (${ARCH})..."
cd "${ROOT}"
rm -rf build && mkdir -p build && cd build
# macOS link notes:
# * No -static: macOS has no static libSystem, so a fully static link is
# impossible. Third-party libs resolve statically from STAGE_DIR (which
# holds only .a archives); zlib, libSystem and libutil resolve
# dynamically -- expected on macOS.
# * libuv on macOS needs the CoreFoundation/CoreServices/IOKit frameworks.
# * -dead_strip is the macOS analogue of GNU ld's --gc-sections.
cmake -DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_OSX_ARCHITECTURES="${ARCH}" \
-DCMAKE_PREFIX_PATH="${STAGE_DIR}" \
-DOPENSSL_ROOT_DIR="${STAGE_DIR}" \
-DOPENSSL_USE_STATIC_LIBS=TRUE \
-DCMAKE_C_FLAGS="-Os -ffunction-sections -fdata-sections" \
-DCMAKE_EXE_LINKER_FLAGS="-Wl,-dead_strip -framework CoreFoundation -framework CoreServices -framework IOKit" \
..
make
strip -S ttyd # -S drops debug symbols; binary stays runnable
}

build_jsonc
build_openssl
build_libuv
build_libwebsockets
build_ttyd

echo "=== Done. Verifying linkage:"
otool -L "${ROOT}/build/ttyd"
"${ROOT}/build/ttyd" --version