diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index e6aaaf909..d474100f5 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -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* diff --git a/README.md b/README.md index d473388c1..666142512 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/macos-build.md b/docs/macos-build.md new file mode 100644 index 000000000..aa1a90af4 --- /dev/null +++ b/docs/macos-build.md @@ -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. diff --git a/scripts/macos-build.sh b/scripts/macos-build.sh new file mode 100755 index 000000000..058b22d34 --- /dev/null +++ b/scripts/macos-build.sh @@ -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--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