]> git.unchartedbackwaters.co.uk Git - francis/winuae.git/commitdiff
tools: add macOS packaging helpers
authorStefan Reinauer <stefan.reinauer@coreboot.org>
Thu, 4 Jun 2026 14:59:36 +0000 (07:59 -0700)
committerStefan Reinauer <stefan.reinauer@coreboot.org>
Thu, 11 Jun 2026 21:08:30 +0000 (14:08 -0700)
Add helpers for building private macOS dependencies, bundling the app,
building the QEMU-PPC plugin, creating the DMG, and validating output.

This keeps the release packaging flow reproducible outside local shell
history.

od-unix/graphics/dmg_background.tiff [new file with mode: 0644]
tools/build-qemu-uae.sh [new file with mode: 0755]
tools/macos-build-deps.sh [new file with mode: 0755]
tools/macos-build-qemu-deps.sh [new file with mode: 0755]
tools/macos-bundle.sh [new file with mode: 0755]
tools/macos-check-deployment-target.sh [new file with mode: 0755]
tools/macos-dmg.sh [new file with mode: 0755]
tools/macos-smoke-app.sh [new file with mode: 0755]
tools/macos-verify-dmg.sh [new file with mode: 0755]

diff --git a/od-unix/graphics/dmg_background.tiff b/od-unix/graphics/dmg_background.tiff
new file mode 100644 (file)
index 0000000..4da049a
Binary files /dev/null and b/od-unix/graphics/dmg_background.tiff differ
diff --git a/tools/build-qemu-uae.sh b/tools/build-qemu-uae.sh
new file mode 100755 (executable)
index 0000000..1183b86
--- /dev/null
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 [qemu-uae-source] [output-plugin]
+
+Builds the QEMU-UAE plugin from the sibling qemu-uae-v11.0 tree.
+
+Environment:
+  WINUAE_MACOS_DEPLOYMENT_TARGET  macOS deployment target for the plugin.
+  QEMU_UAE_DEPS_PREFIX            Private GLib/libslirp dependency prefix.
+  QEMU_UAE_NINJA                  ninja executable. Defaults to ninja in PATH.
+  QEMU_UAE_FORCE_CONFIGURE=1      Run configure-qemu-uae even when an
+                                  existing build/build.ninja is present.
+  QEMU_UAE_SKIP_CONFIGURE=1       Skip configure-qemu-uae. By default,
+                                  configure only runs when build/build.ninja
+                                  is missing.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source_dir="$(cd "${script_dir}/.." && pwd)"
+qemu_source="${1:-${WINUAE_QEMU_UAE_SOURCE_DIR:-${source_dir}/../qemu-uae-v11.0}}"
+output_plugin="${2:-${WINUAE_QEMU_UAE_OUTPUT_PLUGIN:-}}"
+deps_prefix="${QEMU_UAE_DEPS_PREFIX:-${WINUAE_QEMU_UAE_DEPS_PREFIX:-}}"
+
+if [[ ! -x "${qemu_source}/configure-qemu-uae" ]]; then
+    echo "error: configure-qemu-uae not found in ${qemu_source}" >&2
+    exit 1
+fi
+qemu_source="$(cd "${qemu_source}" && pwd)"
+
+if [[ "$(uname -s)" == "Darwin" ]]; then
+    export MACOSX_DEPLOYMENT_TARGET="${WINUAE_MACOS_DEPLOYMENT_TARGET:-${MACOSX_DEPLOYMENT_TARGET:-13.0}}"
+fi
+
+if [[ -n "${deps_prefix}" ]]; then
+    if [[ ! -d "${deps_prefix}" ]]; then
+        echo "error: QEMU_UAE_DEPS_PREFIX does not exist: ${deps_prefix}" >&2
+        exit 1
+    fi
+    deps_prefix="$(cd "${deps_prefix}" && pwd)"
+    export PATH="${deps_prefix}/bin:${PATH}"
+    export PKG_CONFIG_LIBDIR="${deps_prefix}/lib/pkgconfig:${deps_prefix}/share/pkgconfig${PKG_CONFIG_LIBDIR:+:${PKG_CONFIG_LIBDIR}}"
+    if [[ "$(uname -s)" == "Darwin" ]]; then
+        export DYLD_LIBRARY_PATH="${deps_prefix}/lib${DYLD_LIBRARY_PATH:+:${DYLD_LIBRARY_PATH}}"
+    fi
+fi
+
+ninja_executable="${QEMU_UAE_NINJA:-$(command -v ninja || true)}"
+if [[ -z "${ninja_executable}" && -x "${qemu_source}/build/pyvenv/bin/ninja" ]]; then
+    ninja_executable="${qemu_source}/build/pyvenv/bin/ninja"
+fi
+if [[ -z "${ninja_executable}" ]]; then
+    echo "error: ninja not found; set QEMU_UAE_NINJA" >&2
+    exit 1
+fi
+
+if [[ "${QEMU_UAE_SKIP_CONFIGURE:-0}" != "1" && ( "${QEMU_UAE_FORCE_CONFIGURE:-0}" == "1" || ! -f "${qemu_source}/build/build.ninja" ) ]]; then
+    (cd "${qemu_source}" && ./configure-qemu-uae --ninja="${ninja_executable}")
+fi
+
+"${ninja_executable}" -C "${qemu_source}/build" qemu-uae.so
+
+plugin="${qemu_source}/build/qemu-uae.so"
+if [[ ! -f "${plugin}" ]]; then
+    echo "error: qemu-uae.so was not produced" >&2
+    exit 1
+fi
+
+if [[ -n "${output_plugin}" ]]; then
+    mkdir -p "$(dirname "${output_plugin}")"
+    cp "${plugin}" "${output_plugin}"
+    plugin="${output_plugin}"
+fi
+
+echo "${plugin}"
diff --git a/tools/macos-build-deps.sh b/tools/macos-build-deps.sh
new file mode 100755 (executable)
index 0000000..283dff6
--- /dev/null
@@ -0,0 +1,394 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 [prefix]
+
+Build macOS dependencies into a private prefix with a fixed deployment target.
+Use the resulting prefix as CMAKE_PREFIX_PATH when configuring WinUAE.
+
+Arguments:
+  prefix  Install prefix for SDL3, Qt, and optional media libraries.
+          Defaults to WINUAE_DEPS_PREFIX or <repo>/../winuae-macos-deps.
+
+Environment:
+  WINUAE_MACOS_DEPLOYMENT_TARGET  Minimum macOS version. Defaults to 13.0.
+  WINUAE_DEPS_BUILD_DIR           Build directory. Defaults to <prefix>/build.
+  WINUAE_DEPS_JOBS                Parallel build jobs. Defaults to hw.ncpu.
+  WINUAE_SDL3_SOURCE              SDL3 source tree. Required unless
+                                  WINUAE_SKIP_SDL3=1.
+  WINUAE_QT_SOURCE                Qt 6 source tree, or a qtbase CMake source
+                                  tree. Required unless WINUAE_SKIP_QT=1.
+  WINUAE_LIBPNG_SOURCE            libpng source tree. Optional unless
+                                  WINUAE_REQUIRE_LIBPNG=1.
+  WINUAE_FLAC_SOURCE              FLAC source tree. Optional unless
+                                  WINUAE_REQUIRE_FLAC=1.
+  WINUAE_LIBMPEG2_SOURCE          libmpeg2 source tree. Optional unless
+                                  WINUAE_REQUIRE_LIBMPEG2=1.
+  WINUAE_MT32EMU_SOURCE           Munt source tree or mt32emu source tree.
+                                  Optional unless WINUAE_REQUIRE_MT32EMU=1.
+  WINUAE_SKIP_SDL3=1              Do not build SDL3.
+  WINUAE_SKIP_QT=1                Do not build Qt.
+  WINUAE_SKIP_LIBPNG=1            Do not build libpng.
+  WINUAE_SKIP_FLAC=1              Do not build libFLAC.
+  WINUAE_SKIP_LIBMPEG2=1          Do not build libmpeg2.
+  WINUAE_SKIP_MT32EMU=1           Do not build libmt32emu.
+  WINUAE_REQUIRE_LIBPNG=1         Fail if WINUAE_LIBPNG_SOURCE is missing.
+  WINUAE_REQUIRE_FLAC=1           Fail if WINUAE_FLAC_SOURCE is missing.
+  WINUAE_REQUIRE_LIBMPEG2=1       Fail if WINUAE_LIBMPEG2_SOURCE is missing.
+  WINUAE_REQUIRE_MT32EMU=1        Fail if WINUAE_MT32EMU_SOURCE is missing.
+  WINUAE_SDL3_CMAKE_ARGS          Extra arguments passed to SDL3 CMake.
+  WINUAE_QT_CONFIGURE_ARGS        Extra arguments passed to Qt configure,
+                                  in addition to the release-safe defaults.
+  WINUAE_QT_CMAKE_ARGS            Extra arguments passed to Qt CMake.
+  WINUAE_LIBPNG_CMAKE_ARGS        Extra arguments passed to libpng CMake.
+  WINUAE_FLAC_CMAKE_ARGS          Extra arguments passed to FLAC CMake.
+  WINUAE_LIBMPEG2_CONFIGURE_ARGS  Extra arguments passed to libmpeg2 configure.
+  WINUAE_MT32EMU_CMAKE_ARGS       Extra arguments passed to mt32emu CMake.
+
+Extra argument variables are whitespace-separated argv fragments.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source_dir="$(cd "${script_dir}/.." && pwd)"
+target="${WINUAE_MACOS_DEPLOYMENT_TARGET:-13.0}"
+prefix="${1:-${WINUAE_DEPS_PREFIX:-${source_dir}/../winuae-macos-deps}}"
+build_dir="${WINUAE_DEPS_BUILD_DIR:-${prefix}/build}"
+sdl_source="${WINUAE_SDL3_SOURCE:-}"
+qt_source="${WINUAE_QT_SOURCE:-}"
+libpng_source="${WINUAE_LIBPNG_SOURCE:-}"
+flac_source="${WINUAE_FLAC_SOURCE:-}"
+libmpeg2_source="${WINUAE_LIBMPEG2_SOURCE:-}"
+mt32emu_source="${WINUAE_MT32EMU_SOURCE:-}"
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "error: macOS dependency builds require Darwin/macOS" >&2
+    exit 1
+fi
+
+jobs="${WINUAE_DEPS_JOBS:-}"
+if [[ -z "${jobs}" ]]; then
+    jobs="$(sysctl -n hw.ncpu 2>/dev/null || echo 8)"
+fi
+export MACOSX_DEPLOYMENT_TARGET="${target}"
+
+require_source() {
+    local name="$1"
+    local path="$2"
+    if [[ -z "${path}" ]]; then
+        echo "error: ${name} source path is required" >&2
+        usage >&2
+        exit 1
+    fi
+    if [[ ! -d "${path}" ]]; then
+        echo "error: ${name} source directory not found: ${path}" >&2
+        exit 1
+    fi
+}
+
+run_cmake_build() {
+    local src="$1"
+    local bld="$2"
+    shift 2
+    cmake -S "${src}" -B "${bld}" "$@"
+    cmake --build "${bld}" -j "${jobs}"
+    cmake --install "${bld}"
+}
+
+run_autotools_build() {
+    local src="$1"
+    local bld="$2"
+    shift 2
+    if [[ -z "${bld}" || "${bld}" == "/" ]]; then
+        echo "error: refusing unsafe autotools build directory: ${bld}" >&2
+        exit 1
+    fi
+    rm -rf "${bld}"
+    mkdir -p "${bld}/src"
+    cp -R "${src}/." "${bld}/src/"
+    (
+        cd "${bld}/src"
+        if [[ -f Makefile ]]; then
+            make distclean >/dev/null 2>&1 || true
+        fi
+        rm -f config.log config.status
+        env \
+            CFLAGS="${CFLAGS:-} -mmacosx-version-min=${target}" \
+            CXXFLAGS="${CXXFLAGS:-} -mmacosx-version-min=${target}" \
+            LDFLAGS="${LDFLAGS:-} -mmacosx-version-min=${target}" \
+            ./configure "$@"
+        make -j "${jobs}"
+        make install
+    )
+}
+
+patch_qtbase_source() {
+    local header="${qt_source}/src/corelib/thread/qyieldcpu.h"
+    if [[ -f "${header}" ]] && grep -q "__yield();" "${header}" && ! grep -q "arm_acle.h" "${header}"; then
+        perl -0pi -e 's/(#include <QtCore\/qtconfigmacros\.h>\n)/$1\n#if defined(__has_include)\n#  if __has_include(<arm_acle.h>)\n#    include <arm_acle.h>\n#  endif\n#endif\n/' "${header}"
+    fi
+
+    local simd="${qt_source}/src/corelib/global/qsimd.cpp"
+    if [[ -f "${simd}" ]] && grep -q 'sysctlbyname("hw.optional.neon"' "${simd}" && ! grep -q "AArch64 includes Advanced SIMD" "${simd}"; then
+        perl -0pi -e 's/#elif defined\(Q_OS_DARWIN\) && defined\(Q_PROCESSOR_ARM\)\n    unsigned feature;\n    size_t len = sizeof\(feature\);\n    if \(sysctlbyname\("hw\.optional\.neon", &feature, &len, nullptr, 0\) == 0\)\n        features \|= feature \? CpuFeatureNEON : 0;/#elif defined(Q_OS_DARWIN) \&\& defined(Q_PROCESSOR_ARM)\n    unsigned feature;\n    size_t len = sizeof(feature);\n#  if defined(Q_PROCESSOR_ARM_64)\n    \/\/ AArch64 includes Advanced SIMD; some macOS versions no longer\n    \/\/ expose the legacy hw.optional.neon sysctl that Qt probes here.\n    features |= CpuFeatureNEON;\n#  else\n    if (sysctlbyname("hw.optional.neon", \&feature, \&len, nullptr, 0) == 0)\n        features |= feature ? CpuFeatureNEON : 0;\n#  endif/' "${simd}"
+    fi
+}
+
+split_extra_args() {
+    extra_args=()
+    if [[ -n "${1:-}" ]]; then
+        # Extra-arg variables are whitespace-separated argv fragments.
+        # Split once here and pass the result as an array at call sites.
+        # shellcheck disable=SC2206
+        extra_args=($1)
+    fi
+}
+
+mkdir -p "${prefix}" "${build_dir}"
+
+if [[ "${WINUAE_SKIP_LIBPNG:-0}" != "1" ]]; then
+    if [[ -n "${libpng_source}" ]]; then
+        require_source "libpng" "${libpng_source}"
+        libpng_cmake_args=(
+            -DCMAKE_BUILD_TYPE=Release
+            -DCMAKE_INSTALL_PREFIX="${prefix}"
+            -DCMAKE_OSX_DEPLOYMENT_TARGET="${target}"
+            -DPNG_SHARED=ON
+            -DPNG_STATIC=OFF
+            -DPNG_TESTS=OFF
+            -DPNG_TOOLS=OFF
+        )
+        split_extra_args "${WINUAE_LIBPNG_CMAKE_ARGS:-}"
+        libpng_cmake_args+=(${extra_args[@]+"${extra_args[@]}"})
+        run_cmake_build "${libpng_source}" "${build_dir}/libpng" \
+            "${libpng_cmake_args[@]}"
+    elif [[ "${WINUAE_REQUIRE_LIBPNG:-0}" == "1" ]]; then
+        echo "error: libpng source path is required when WINUAE_REQUIRE_LIBPNG=1" >&2
+        usage >&2
+        exit 1
+    else
+        echo "note: WINUAE_LIBPNG_SOURCE not set; skipping private libpng build" >&2
+    fi
+fi
+
+if [[ "${WINUAE_SKIP_FLAC:-0}" != "1" ]]; then
+    if [[ -n "${flac_source}" ]]; then
+        require_source "FLAC" "${flac_source}"
+        flac_cmake_args=(
+            -DCMAKE_BUILD_TYPE=Release
+            -DCMAKE_INSTALL_PREFIX="${prefix}"
+            -DCMAKE_OSX_DEPLOYMENT_TARGET="${target}"
+            -DBUILD_SHARED_LIBS=ON
+            -DBUILD_CXXLIBS=OFF
+            -DBUILD_PROGRAMS=OFF
+            -DBUILD_EXAMPLES=OFF
+            -DBUILD_DOCS=OFF
+            -DBUILD_TESTING=OFF
+            -DINSTALL_MANPAGES=OFF
+            -DINSTALL_PKGCONFIG_MODULES=ON
+            -DWITH_OGG=OFF
+        )
+        split_extra_args "${WINUAE_FLAC_CMAKE_ARGS:-}"
+        flac_cmake_args+=(${extra_args[@]+"${extra_args[@]}"})
+        run_cmake_build "${flac_source}" "${build_dir}/flac" \
+            "${flac_cmake_args[@]}"
+    elif [[ "${WINUAE_REQUIRE_FLAC:-0}" == "1" ]]; then
+        echo "error: FLAC source path is required when WINUAE_REQUIRE_FLAC=1" >&2
+        usage >&2
+        exit 1
+    else
+        echo "note: WINUAE_FLAC_SOURCE not set; skipping private libFLAC build" >&2
+    fi
+fi
+
+if [[ "${WINUAE_SKIP_LIBMPEG2:-0}" != "1" ]]; then
+    if [[ -n "${libmpeg2_source}" ]]; then
+        require_source "libmpeg2" "${libmpeg2_source}"
+        libmpeg2_configure_args=(
+            --prefix="${prefix}"
+            --disable-sdl
+            --without-x
+            --enable-shared
+            --disable-static
+            --disable-dependency-tracking
+        )
+        if [[ -n "${WINUAE_LIBMPEG2_CONFIGURE_ARGS:-}" ]]; then
+            split_extra_args "${WINUAE_LIBMPEG2_CONFIGURE_ARGS}"
+            libmpeg2_configure_args+=(${extra_args[@]+"${extra_args[@]}"})
+        fi
+        run_autotools_build "${libmpeg2_source}" "${build_dir}/libmpeg2" \
+            "${libmpeg2_configure_args[@]}"
+        (
+            cd "${build_dir}/libmpeg2/src"
+            make -C libmpeg2 install
+            make -C include install
+        )
+    elif [[ "${WINUAE_REQUIRE_LIBMPEG2:-0}" == "1" ]]; then
+        echo "error: libmpeg2 source path is required when WINUAE_REQUIRE_LIBMPEG2=1" >&2
+        usage >&2
+        exit 1
+    else
+        echo "note: WINUAE_LIBMPEG2_SOURCE not set; skipping private libmpeg2 build" >&2
+    fi
+fi
+
+if [[ "${WINUAE_SKIP_MT32EMU:-0}" != "1" ]]; then
+    if [[ -n "${mt32emu_source}" ]]; then
+        require_source "Munt/mt32emu" "${mt32emu_source}"
+        mt32emu_cmake_source="${mt32emu_source}"
+        if [[ -f "${mt32emu_source}/mt32emu/CMakeLists.txt" ]]; then
+            mt32emu_cmake_source="${mt32emu_source}/mt32emu"
+        fi
+        if [[ ! -f "${mt32emu_cmake_source}/CMakeLists.txt" ]]; then
+            echo "error: mt32emu CMakeLists.txt not found under ${mt32emu_source}" >&2
+            exit 1
+        fi
+        mt32emu_cmake_args=(
+            -DCMAKE_BUILD_TYPE=Release
+            -DCMAKE_INSTALL_PREFIX="${prefix}"
+            -DCMAKE_OSX_DEPLOYMENT_TARGET="${target}"
+            -DBUILD_TESTING=OFF
+            -Dlibmt32emu_SHARED=ON
+            -Dlibmt32emu_C_INTERFACE=ON
+            -Dlibmt32emu_CPP_INTERFACE=OFF
+            -Dlibmt32emu_WITH_INTERNAL_RESAMPLER=ON
+        )
+        split_extra_args "${WINUAE_MT32EMU_CMAKE_ARGS:-}"
+        mt32emu_cmake_args+=(${extra_args[@]+"${extra_args[@]}"})
+        run_cmake_build "${mt32emu_cmake_source}" "${build_dir}/mt32emu" \
+            "${mt32emu_cmake_args[@]}"
+    elif [[ "${WINUAE_REQUIRE_MT32EMU:-0}" == "1" ]]; then
+        echo "error: Munt/mt32emu source path is required when WINUAE_REQUIRE_MT32EMU=1" >&2
+        usage >&2
+        exit 1
+    else
+        echo "note: WINUAE_MT32EMU_SOURCE not set; skipping private libmt32emu build" >&2
+    fi
+fi
+
+if [[ "${WINUAE_SKIP_SDL3:-0}" != "1" ]]; then
+    require_source "SDL3" "${sdl_source}"
+    sdl3_cmake_args=(
+        -DCMAKE_BUILD_TYPE=Release
+        -DCMAKE_INSTALL_PREFIX="${prefix}"
+        -DCMAKE_OSX_DEPLOYMENT_TARGET="${target}"
+        -DSDL_SHARED=ON
+        -DSDL_STATIC=OFF
+        -DSDL_TESTS=OFF
+        -DSDL_EXAMPLES=OFF
+        -DSDL_INSTALL_TESTS=OFF
+    )
+    split_extra_args "${WINUAE_SDL3_CMAKE_ARGS:-}"
+    sdl3_cmake_args+=(${extra_args[@]+"${extra_args[@]}"})
+    run_cmake_build "${sdl_source}" "${build_dir}/sdl3" \
+        "${sdl3_cmake_args[@]}"
+fi
+
+if [[ "${WINUAE_SKIP_QT:-0}" != "1" ]]; then
+    require_source "Qt" "${qt_source}"
+    qt_build="${build_dir}/qt"
+    mkdir -p "${qt_build}"
+    patch_qtbase_source
+
+    qt_configure_args=(
+        -force-bundled-libs
+        -no-dbus
+        -no-openssl
+        -no-glib
+        -no-icu
+        -no-cups
+        -no-fontconfig
+        -no-gtk
+        -qt-doubleconversion
+        -qt-pcre
+        -qt-zlib
+        -qt-libpng
+        -qt-libjpeg
+        -qt-freetype
+        -qt-harfbuzz
+    )
+    if [[ -n "${WINUAE_QT_CONFIGURE_ARGS:-}" ]]; then
+        split_extra_args "${WINUAE_QT_CONFIGURE_ARGS}"
+        qt_configure_args+=(${extra_args[@]+"${extra_args[@]}"})
+    fi
+
+    qt_cmake_args=()
+    if ! xcodebuild -version >/dev/null 2>&1 && xcrun --show-sdk-path >/dev/null 2>&1; then
+        qt_cmake_args+=(-DQT_NO_XCODE_MIN_VERSION_CHECK=ON)
+    fi
+    if [[ -n "${WINUAE_QT_CMAKE_ARGS:-}" ]]; then
+        split_extra_args "${WINUAE_QT_CMAKE_ARGS}"
+        qt_cmake_args+=(${extra_args[@]+"${extra_args[@]}"})
+    fi
+
+    qt_submodule_args=()
+    if [[ -d "${qt_source}/qtbase" || -f "${qt_source}/init-repository" ]]; then
+        qt_submodule_args=(-submodules qtbase)
+    fi
+
+    if [[ -x "${qt_source}/configure" && ! -d "${qt_source}/src/corelib" ]]; then
+        (
+            cd "${qt_build}"
+            "${qt_source}/configure" \
+                -prefix "${prefix}" \
+                -release \
+                -opensource \
+                -confirm-license \
+                -nomake examples \
+                -nomake tests \
+                ${qt_submodule_args[@]+"${qt_submodule_args[@]}"} \
+                "${qt_configure_args[@]}" \
+                -- \
+                -DCMAKE_OSX_DEPLOYMENT_TARGET="${target}" \
+                ${qt_cmake_args[@]+"${qt_cmake_args[@]}"}
+        )
+        cmake --build "${qt_build}" -j "${jobs}"
+        cmake --install "${qt_build}"
+    else
+        run_cmake_build "${qt_source}" "${qt_build}" \
+            -DCMAKE_BUILD_TYPE=Release \
+            -DCMAKE_INSTALL_PREFIX="${prefix}" \
+            -DCMAKE_OSX_DEPLOYMENT_TARGET="${target}" \
+            -DQT_BUILD_EXAMPLES=OFF \
+            -DQT_BUILD_TESTS=OFF \
+            -DQT_FEATURE_dbus=OFF \
+            -DQT_FEATURE_openssl=OFF \
+            -DQT_FEATURE_glib=OFF \
+            -DQT_FEATURE_icu=OFF \
+            -DQT_FEATURE_cups=OFF \
+            -DQT_FEATURE_fontconfig=OFF \
+            -DQT_FEATURE_gtk=OFF \
+            -DQT_FEATURE_opengl=OFF \
+            -DQT_FEATURE_opengles2=OFF \
+            ${qt_cmake_args[@]+"${qt_cmake_args[@]}"}
+    fi
+fi
+
+"${script_dir}/macos-check-deployment-target.sh" "${prefix}" "${target}"
+
+env_file="${prefix}/winuae-macos-deps-env.sh"
+cat > "${env_file}" <<EOF
+export CMAKE_PREFIX_PATH="${prefix}\${CMAKE_PREFIX_PATH:+:\${CMAKE_PREFIX_PATH}}"
+export PKG_CONFIG_PATH="${prefix}/lib/pkgconfig:${prefix}/share/pkgconfig\${PKG_CONFIG_PATH:+:\${PKG_CONFIG_PATH}}"
+export PATH="${prefix}/bin:\${PATH}"
+export WINUAE_MACOS_DEPLOYMENT_TARGET="${target}"
+EOF
+
+cat <<EOF
+macOS dependencies installed to: ${prefix}
+Deployment target verified: ${target}
+
+Use:
+  source "${env_file}"
+  cmake -S "${source_dir}" -B /tmp/winuae_cmake_macos \\
+    -DCMAKE_BUILD_TYPE=RelWithDebInfo \\
+    -DCMAKE_OSX_DEPLOYMENT_TARGET="${target}" \\
+    -DCMAKE_PREFIX_PATH="${prefix}"
+EOF
diff --git a/tools/macos-build-qemu-deps.sh b/tools/macos-build-qemu-deps.sh
new file mode 100755 (executable)
index 0000000..97a9527
--- /dev/null
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 [prefix]
+
+Build QEMU-UAE macOS dependencies into a private prefix with a fixed
+deployment target. The resulting prefix is intended for the QEMU-UAE plugin
+build and keeps the plugin from linking against newer Homebrew libraries.
+
+Arguments:
+  prefix  Install prefix for GLib. Defaults to
+          WINUAE_QEMU_DEPS_PREFIX, WINUAE_DEPS_PREFIX, or
+          <repo>/../winuae-macos-deps.
+
+Environment:
+  WINUAE_MACOS_DEPLOYMENT_TARGET  Minimum macOS version. Defaults to 13.0.
+  WINUAE_QEMU_DEPS_BUILD_DIR      Build directory. Defaults to
+                                  <prefix>/build/qemu-deps.
+  WINUAE_DEPS_JOBS                Parallel build jobs. Defaults to hw.ncpu.
+  WINUAE_GLIB_SOURCE              GLib source tree. Required.
+  WINUAE_MESON                    meson executable. Defaults to meson in PATH
+                                  or ../qemu-uae-v11.0/build/pyvenv/bin/meson.
+  WINUAE_NINJA                    ninja executable. Defaults to ninja in PATH.
+  WINUAE_QEMU_BUILD_TOOLS_DIR     Optional tools prefix. If set,
+                                  <prefix>/bin/ninja is used as fallback.
+  WINUAE_GLIB_MESON_ARGS          Extra arguments passed to GLib Meson.
+
+Extra argument variables are whitespace-separated argv fragments.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source_dir="$(cd "${script_dir}/.." && pwd)"
+target="${WINUAE_MACOS_DEPLOYMENT_TARGET:-13.0}"
+prefix="${1:-${WINUAE_QEMU_DEPS_PREFIX:-${WINUAE_DEPS_PREFIX:-${source_dir}/../winuae-macos-deps}}}"
+build_dir="${WINUAE_QEMU_DEPS_BUILD_DIR:-${prefix}/build/qemu-deps}"
+glib_source="${WINUAE_GLIB_SOURCE:-}"
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "error: macOS QEMU dependency builds require Darwin/macOS" >&2
+    exit 1
+fi
+
+jobs="${WINUAE_DEPS_JOBS:-}"
+if [[ -z "${jobs}" ]]; then
+    jobs="$(sysctl -n hw.ncpu 2>/dev/null || echo 8)"
+fi
+
+require_source() {
+    local name="$1"
+    local path="$2"
+    if [[ -z "${path}" ]]; then
+        echo "error: ${name} source path is required" >&2
+        usage >&2
+        exit 1
+    fi
+    if [[ ! -d "${path}" ]]; then
+        echo "error: ${name} source directory not found: ${path}" >&2
+        exit 1
+    fi
+}
+
+find_tool() {
+    local env_value="$1"
+    local tool_name="$2"
+    local fallback="$3"
+
+    if [[ -n "${env_value}" ]]; then
+        echo "${env_value}"
+        return
+    fi
+    if command -v "${tool_name}" >/dev/null 2>&1; then
+        command -v "${tool_name}"
+        return
+    fi
+    if [[ -n "${fallback}" && -x "${fallback}" ]]; then
+        echo "${fallback}"
+        return
+    fi
+
+    echo "error: ${tool_name} not found" >&2
+    exit 1
+}
+
+split_extra_args() {
+    extra_args=()
+    if [[ -n "${1:-}" ]]; then
+        # Extra-arg variables are whitespace-separated argv fragments.
+        # Split once here and pass the result as an array at call sites.
+        # shellcheck disable=SC2206
+        extra_args=($1)
+    fi
+}
+
+run_meson_build() {
+    local src="$1"
+    local bld="$2"
+    shift 2
+
+    if [[ -f "${bld}/build.ninja" ]]; then
+        "${meson_executable}" setup --reconfigure "${bld}" "$@"
+    else
+        "${meson_executable}" setup "${bld}" "${src}" "$@"
+    fi
+    "${ninja_executable}" -C "${bld}" -j "${jobs}"
+    "${ninja_executable}" -C "${bld}" install
+}
+
+require_source "GLib" "${glib_source}"
+
+meson_fallback="${source_dir}/../qemu-uae-v11.0/build/pyvenv/bin/meson"
+ninja_fallback=""
+if [[ -n "${WINUAE_QEMU_BUILD_TOOLS_DIR:-}" ]]; then
+    ninja_fallback="${WINUAE_QEMU_BUILD_TOOLS_DIR}/bin/ninja"
+fi
+meson_executable="$(find_tool "${WINUAE_MESON:-}" meson "${meson_fallback}")"
+ninja_executable="$(find_tool "${WINUAE_NINJA:-}" ninja "${ninja_fallback}")"
+
+mkdir -p "${prefix}" "${build_dir}"
+
+export MACOSX_DEPLOYMENT_TARGET="${target}"
+export PATH="$(dirname "${ninja_executable}"):${prefix}/bin:${PATH}"
+export PKG_CONFIG_LIBDIR="${prefix}/lib/pkgconfig:${prefix}/share/pkgconfig"
+
+common_meson_args=(
+    --prefix="${prefix}"
+    --libdir=lib
+    --buildtype=release
+    -Ddefault_library=shared
+)
+
+glib_args=(
+    "${common_meson_args[@]}"
+    --force-fallback-for=libpcre2-8,libffi,intl
+    -Dtests=false
+    -Dinstalled_tests=false
+    -Dglib_debug=disabled
+    -Dglib_assert=false
+    -Dglib_checks=false
+    -Dman-pages=disabled
+    -Ddocumentation=false
+    -Dgtk_doc=false
+    -Dnls=disabled
+    -Dselinux=disabled
+    -Dxattr=false
+    -Dlibmount=disabled
+    -Dsysprof=disabled
+    -Dintrospection=disabled
+    -Ddtrace=disabled
+    -Dsystemtap=disabled
+)
+if [[ -n "${WINUAE_GLIB_MESON_ARGS:-}" ]]; then
+    split_extra_args "${WINUAE_GLIB_MESON_ARGS}"
+    glib_args+=(${extra_args[@]+"${extra_args[@]}"})
+fi
+
+run_meson_build "${glib_source}" "${build_dir}/glib" "${glib_args[@]}"
+
+"${script_dir}/macos-check-deployment-target.sh" "${prefix}" "${target}"
+
+env_file="${prefix}/winuae-macos-deps-env.sh"
+cat > "${env_file}" <<EOF
+export CMAKE_PREFIX_PATH="${prefix}\${CMAKE_PREFIX_PATH:+:\${CMAKE_PREFIX_PATH}}"
+export PKG_CONFIG_PATH="${prefix}/lib/pkgconfig:${prefix}/share/pkgconfig\${PKG_CONFIG_PATH:+:\${PKG_CONFIG_PATH}}"
+export QEMU_UAE_DEPS_PREFIX="${prefix}"
+export WINUAE_MACOS_DEPLOYMENT_TARGET="${target}"
+export PATH="${prefix}/bin:\${PATH}"
+EOF
+
+cat <<EOF
+macOS QEMU-UAE dependencies installed to: ${prefix}
+Deployment target verified: ${target}
+
+Use:
+  source "${env_file}"
+  cmake --build /tmp/winuae_cmake_macos \\
+    --target winuae_unix_qemu_uae_plugin -j
+EOF
diff --git a/tools/macos-bundle.sh b/tools/macos-bundle.sh
new file mode 100755 (executable)
index 0000000..83ea48d
--- /dev/null
@@ -0,0 +1,326 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 [build-dir] [output-dir]
+
+Creates a local WinUAE.app bundle from an existing macOS build tree.
+
+Arguments:
+  build-dir   CMake build directory containing winuae_unix.
+              Defaults to WINUAE_BUILD_DIR or the current directory.
+  output-dir  Directory that will receive WinUAE.app.
+              Defaults to <build-dir>/package.
+
+Environment:
+  WINUAE_SKIP_MACDEPLOYQT=1  Do not run macdeployqt even if it is available.
+  WINUAE_SKIP_MACOS_DEPLOYMENT_CHECK=1
+                              Do not check bundled Mach-O deployment targets.
+  WINUAE_SKIP_CODESIGN=1     Do not ad-hoc codesign the bundle.
+  WINUAE_CODESIGN_IDENTITY   codesign identity. Defaults to "-" for ad-hoc.
+  WINUAE_CODESIGN_OPTIONS    Extra options passed to codesign, for example
+                              "--options runtime --timestamp".
+  WINUAE_CODESIGN_ENTITLEMENTS
+                              Optional entitlements plist passed to codesign.
+  WINUAE_QEMU_UAE_PLUGIN      Optional qemu-uae.so path to copy into
+                              Contents/PlugIns.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source_dir="$(cd "${script_dir}/.." && pwd)"
+build_dir="${1:-${WINUAE_BUILD_DIR:-$(pwd)}}"
+output_dir="${2:-${build_dir}/package}"
+executable="${build_dir}/winuae_unix"
+app_dir="${output_dir}/WinUAE.app"
+contents_dir="${app_dir}/Contents"
+macos_dir="${contents_dir}/MacOS"
+resources_dir="${contents_dir}/Resources"
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "error: macOS app bundling requires Darwin/macOS" >&2
+    exit 1
+fi
+
+if [[ ! -x "${executable}" ]]; then
+    echo "error: executable not found: ${executable}" >&2
+    echo "hint: build winuae_unix first, or pass the CMake build directory" >&2
+    exit 1
+fi
+
+major="$(awk '/^#define UAEMAJOR / { print $3; exit }' "${source_dir}/include/options.h")"
+minor="$(awk '/^#define UAEMINOR / { print $3; exit }' "${source_dir}/include/options.h")"
+revision="$(awk '/^#define UAESUBREV / { print $3; exit }' "${source_dir}/include/options.h")"
+version="${major:-0}.${minor:-0}.${revision:-0}"
+deployment_target="${WINUAE_MACOS_DEPLOYMENT_TARGET:-}"
+if [[ -z "${deployment_target}" && -f "${build_dir}/CMakeCache.txt" ]]; then
+    deployment_target="$(awk -F= '/^CMAKE_OSX_DEPLOYMENT_TARGET:/ { print $2; exit }' "${build_dir}/CMakeCache.txt")"
+fi
+if [[ -z "${deployment_target}" ]]; then
+    deployment_target="13.0"
+fi
+
+cmake_cache_value() {
+    local key="$1"
+    if [[ -f "${build_dir}/CMakeCache.txt" ]]; then
+        awk -F= -v key="${key}" '$1 ~ "^" key ":" { print $2; exit }' "${build_dir}/CMakeCache.txt"
+    fi
+}
+
+append_qt_plugin_candidate() {
+    local candidate="$1"
+    if [[ -n "${candidate}" && -f "${candidate}/platforms/libqcocoa.dylib" ]]; then
+        qt_plugin_candidates+=("${candidate}")
+    fi
+}
+
+split_extra_args() {
+    if [[ -n "${1:-}" ]]; then
+        # Intentionally split user-provided extra flags the same way a shell would.
+        # shellcheck disable=SC2206
+        extra_args=($1)
+    else
+        extra_args=()
+    fi
+}
+
+path_in_list() {
+    local needle="$1"
+    shift
+    local item
+    for item in "$@"; do
+        if [[ "${item}" == "${needle}" ]]; then
+            return 0
+        fi
+    done
+    return 1
+}
+
+copy_private_dylib_deps() {
+    local root_binary="$1"
+    local install_prefix="$2"
+    local frameworks_dir="${contents_dir}/Frameworks"
+    local queue=("${root_binary}")
+    local visited=()
+
+    mkdir -p "${frameworks_dir}"
+    while [[ ${#queue[@]} -gt 0 ]]; do
+        local binary="${queue[0]}"
+        queue=("${queue[@]:1}")
+        if path_in_list "${binary}" ${visited[@]+"${visited[@]}"}; then
+            continue
+        fi
+        visited+=("${binary}")
+
+        local dep
+        while IFS= read -r dep; do
+            case "${dep}" in
+                ""|@*|/usr/lib/*|/System/Library/*)
+                    continue
+                    ;;
+            esac
+            if [[ ! -f "${dep}" ]]; then
+                continue
+            fi
+
+            local name target
+            name="$(basename "${dep}")"
+            target="${frameworks_dir}/${name}"
+            if [[ ! -f "${target}" ]]; then
+                cp "${dep}" "${target}"
+                chmod u+w "${target}" 2>/dev/null || true
+                install_name_tool -id "@rpath/${name}" "${target}" \
+                    2>/dev/null || true
+            fi
+            install_name_tool -change "${dep}" "${install_prefix}/${name}" \
+                "${binary}" 2>/dev/null || true
+            if ! path_in_list "${target}" ${visited[@]+"${visited[@]}"} \
+                    && ! path_in_list "${target}" ${queue[@]+"${queue[@]}"}; then
+                queue+=("${target}")
+            fi
+        done < <(otool -L "${binary}" | awk 'NR > 1 { print $1 }')
+    done
+}
+
+rm -rf "${app_dir}"
+mkdir -p "${macos_dir}" "${resources_dir}/od-win32/resources"
+
+cp "${executable}" "${macos_dir}/WinUAE"
+find "${source_dir}/od-win32/resources" -maxdepth 1 -type f \
+    ! -name '*.rc' \
+    ! -name '*.manifest' \
+    ! -name 'resource.h' \
+    -exec cp '{}' "${resources_dir}/od-win32/resources/" ';'
+cp "${source_dir}/README_unix.md" "${resources_dir}/README_unix.md"
+
+if [[ -f "${source_dir}/od-win32/resources/winuae.ico" ]]; then
+    cp "${source_dir}/od-win32/resources/winuae.ico" "${resources_dir}/winuae.ico"
+    if command -v sips >/dev/null 2>&1; then
+        sips -s format icns "${source_dir}/od-win32/resources/winuae.ico" --out "${resources_dir}/WinUAE.icns" >/dev/null
+    fi
+fi
+
+cat > "${contents_dir}/Info.plist" <<EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
+    "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>CFBundleDevelopmentRegion</key>
+    <string>en</string>
+    <key>CFBundleDisplayName</key>
+    <string>WinUAE</string>
+    <key>CFBundleExecutable</key>
+    <string>WinUAE</string>
+    <key>CFBundleIdentifier</key>
+    <string>net.winuae.unix</string>
+    <key>CFBundleIconFile</key>
+    <string>WinUAE.icns</string>
+    <key>CFBundleInfoDictionaryVersion</key>
+    <string>6.0</string>
+    <key>CFBundleName</key>
+    <string>WinUAE</string>
+    <key>CFBundlePackageType</key>
+    <string>APPL</string>
+    <key>CFBundleShortVersionString</key>
+    <string>${version}</string>
+    <key>CFBundleVersion</key>
+    <string>${version}</string>
+    <key>LSMinimumSystemVersion</key>
+    <string>${deployment_target}</string>
+    <key>NSHighResolutionCapable</key>
+    <true/>
+    <key>CFBundleDocumentTypes</key>
+    <array>
+        <dict>
+            <key>CFBundleTypeExtensions</key>
+            <array>
+                <string>uae</string>
+            </array>
+            <key>CFBundleTypeName</key>
+            <string>WinUAE Configuration</string>
+            <key>CFBundleTypeRole</key>
+            <string>Editor</string>
+        </dict>
+    </array>
+</dict>
+</plist>
+EOF
+
+qemu_uae_plugin=""
+for candidate in \
+    "${WINUAE_QEMU_UAE_PLUGIN:-}" \
+    "${build_dir}/qemu-uae.so" \
+    "${build_dir}/plugins/qemu-uae.so" \
+    "${source_dir}/../qemu-uae-v11.0/build/qemu-uae.so"
+do
+    if [[ -n "${candidate}" && -f "${candidate}" ]]; then
+        qemu_uae_plugin="${candidate}"
+        break
+    fi
+done
+
+copy_qemu_uae_plugin() {
+    if [[ -n "${qemu_uae_plugin}" ]]; then
+        mkdir -p "${contents_dir}/PlugIns"
+        cp "${qemu_uae_plugin}" "${contents_dir}/PlugIns/qemu-uae.so"
+        install_name_tool -add_rpath "@loader_path/../Frameworks" \
+            "${contents_dir}/PlugIns/qemu-uae.so" 2>/dev/null || true
+        copy_private_dylib_deps \
+            "${contents_dir}/PlugIns/qemu-uae.so" \
+            "@loader_path/../Frameworks"
+    fi
+}
+
+macdeployqt_executable="${WINUAE_MACDEPLOYQT:-}"
+if [[ -z "${macdeployqt_executable}" ]]; then
+    macdeployqt_executable="$(cmake_cache_value MACDEPLOYQT_EXECUTABLE)"
+fi
+if [[ -z "${macdeployqt_executable}" ]]; then
+    macdeployqt_executable="$(command -v macdeployqt || true)"
+fi
+
+if [[ "${WINUAE_SKIP_MACDEPLOYQT:-0}" != "1" && -n "${macdeployqt_executable}" && -x "${macdeployqt_executable}" ]]; then
+    macdeployqt_args=("${app_dir}" -always-overwrite -no-plugins -verbose=0)
+    if "${macdeployqt_executable}" -help 2>&1 | grep -q -- "-no-codesign"; then
+        macdeployqt_args+=(-no-codesign)
+    fi
+    "${macdeployqt_executable}" "${macdeployqt_args[@]}"
+
+    qt_plugin_root=""
+    qt_plugin_candidates=()
+    append_qt_plugin_candidate "${WINUAE_QT_PLUGIN_ROOT:-}"
+
+    qt6_dir="$(cmake_cache_value Qt6_DIR)"
+    if [[ -n "${qt6_dir}" && -d "${qt6_dir}" ]]; then
+        qt_prefix="$(cd "${qt6_dir}/../../.." && pwd)"
+        append_qt_plugin_candidate "${qt_prefix}/plugins"
+        append_qt_plugin_candidate "${qt_prefix}/share/qt/plugins"
+        append_qt_plugin_candidate "${qt_prefix}/share/qt6/plugins"
+        if [[ -x "${qt_prefix}/bin/qtpaths" ]]; then
+            append_qt_plugin_candidate "$("${qt_prefix}/bin/qtpaths" --plugin-dir 2>/dev/null || true)"
+        fi
+    fi
+
+    macdeployqt_dir="$(cd "$(dirname "${macdeployqt_executable}")" && pwd)"
+    if [[ -x "${macdeployqt_dir}/qtpaths" ]]; then
+        append_qt_plugin_candidate "$("${macdeployqt_dir}/qtpaths" --plugin-dir 2>/dev/null || true)"
+    fi
+
+    for candidate in "${qt_plugin_candidates[@]}" \
+        /opt/homebrew/share/qt/plugins \
+        /opt/homebrew/opt/qt/share/qt/plugins \
+        /opt/homebrew/opt/qt6/share/qt/plugins \
+        /opt/homebrew/opt/qt@6/share/qt/plugins \
+        /usr/local/share/qt/plugins \
+        /usr/local/opt/qt/share/qt/plugins \
+        /usr/local/opt/qt6/share/qt/plugins \
+        /usr/local/opt/qt@6/share/qt/plugins
+    do
+        if [[ -n "${candidate}" && -f "${candidate}/platforms/libqcocoa.dylib" ]]; then
+            qt_plugin_root="${candidate}"
+            break
+        fi
+    done
+
+    if [[ -n "${qt_plugin_root}" ]]; then
+        copy_qt_plugin() {
+            local relative="$1"
+            local source="${qt_plugin_root}/${relative}"
+            local target="${contents_dir}/PlugIns/${relative}"
+            if [[ -f "${source}" ]]; then
+                mkdir -p "$(dirname "${target}")"
+                cp "${source}" "${target}"
+                install_name_tool -add_rpath "@loader_path/../../Frameworks" "${target}" 2>/dev/null || true
+            fi
+        }
+        copy_qt_plugin "platforms/libqcocoa.dylib"
+        copy_qt_plugin "imageformats/libqico.dylib"
+        copy_qt_plugin "styles/libqmacstyle.dylib"
+    fi
+fi
+
+copy_qemu_uae_plugin
+
+if [[ "${WINUAE_SKIP_MACOS_DEPLOYMENT_CHECK:-0}" != "1" ]]; then
+    "${script_dir}/macos-check-deployment-target.sh" "${app_dir}" "${deployment_target}" >&2
+fi
+
+if [[ "${WINUAE_SKIP_CODESIGN:-0}" != "1" ]] && command -v codesign >/dev/null 2>&1; then
+    codesign_identity="${WINUAE_CODESIGN_IDENTITY:--}"
+    codesign_args=(--force --deep --sign "${codesign_identity}")
+    split_extra_args "${WINUAE_CODESIGN_OPTIONS:-}"
+    codesign_args+=(${extra_args[@]+"${extra_args[@]}"})
+    if [[ -n "${WINUAE_CODESIGN_ENTITLEMENTS:-}" ]]; then
+        codesign_args+=(--entitlements "${WINUAE_CODESIGN_ENTITLEMENTS}")
+    fi
+    codesign "${codesign_args[@]}" "${app_dir}"
+fi
+
+echo "${app_dir}"
diff --git a/tools/macos-check-deployment-target.sh b/tools/macos-check-deployment-target.sh
new file mode 100755 (executable)
index 0000000..560bbae
--- /dev/null
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 app-or-binary-path deployment-target
+
+Checks Mach-O files under a macOS app bundle, or a single Mach-O file, and
+fails if any file requires a macOS version newer than the requested deployment
+target.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+root="${1:-}"
+target="${2:-}"
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "error: deployment-target checking requires Darwin/macOS" >&2
+    exit 1
+fi
+if [[ -z "${root}" || -z "${target}" ]]; then
+    usage >&2
+    exit 1
+fi
+if [[ ! -e "${root}" ]]; then
+    echo "error: path not found: ${root}" >&2
+    exit 1
+fi
+
+version_gt() {
+    local a="$1"
+    local b="$2"
+    local ai bi
+    IFS=. read -r -a av <<< "${a}"
+    IFS=. read -r -a bv <<< "${b}"
+    for i in 0 1 2; do
+        ai="${av[$i]:-0}"
+        bi="${bv[$i]:-0}"
+        if ((10#${ai} > 10#${bi})); then
+            return 0
+        fi
+        if ((10#${ai} < 10#${bi})); then
+            return 1
+        fi
+    done
+    return 1
+}
+
+mach_o_minos() {
+    otool -l "$1" 2>/dev/null | awk '
+        /LC_BUILD_VERSION/ { build = 1; old = 0; next }
+        build && /minos/ { print $2; exit }
+        /LC_VERSION_MIN_MACOSX/ { old = 1; build = 0; next }
+        old && /version/ { print $2; exit }
+    '
+}
+
+check_file() {
+    local file="$1"
+    local info minos
+
+    info="$(file -b "${file}" 2>/dev/null || true)"
+    case "${info}" in
+        *Mach-O*) ;;
+        *) return 0 ;;
+    esac
+
+    minos="$(mach_o_minos "${file}")"
+    if [[ -n "${minos}" ]] && version_gt "${minos}" "${target}"; then
+        echo "error: ${file} requires macOS ${minos}, newer than deployment target ${target}" >&2
+        return 1
+    fi
+    return 0
+}
+
+failed=0
+if [[ -f "${root}" ]]; then
+    check_file "${root}" || failed=1
+else
+    while IFS= read -r -d '' file_path; do
+        check_file "${file_path}" || failed=1
+    done < <(find "${root}" -type f -print0)
+fi
+
+if ((failed)); then
+    exit 1
+fi
+echo "verified macOS deployment target ${target}: ${root}"
diff --git a/tools/macos-dmg.sh b/tools/macos-dmg.sh
new file mode 100755 (executable)
index 0000000..b811a4a
--- /dev/null
@@ -0,0 +1,231 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 [build-dir] [output-dir]
+
+Creates a drag-install WinUAE DMG from an existing macOS build tree.
+
+Arguments:
+  build-dir   CMake build directory containing winuae_unix.
+              Defaults to WINUAE_BUILD_DIR or the current directory.
+  output-dir  Directory that will receive WinUAE.app and the final DMG.
+              Defaults to <build-dir>/package.
+
+Environment:
+  WINUAE_DMG_CODESIGN_IDENTITY codesign identity for the final DMG.
+                               Defaults to WINUAE_CODESIGN_IDENTITY when set.
+  WINUAE_DMG_CODESIGN_OPTIONS  Extra options passed to codesign for the DMG.
+  WINUAE_NOTARY_PROFILE        notarytool keychain profile. When set, submit
+                               the final DMG and staple the ticket.
+  WINUAE_SKIP_NOTARIZATION=1   Do not submit/staple even if a profile is set.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source_dir="$(cd "${script_dir}/.." && pwd)"
+build_dir="${1:-${WINUAE_BUILD_DIR:-$(pwd)}}"
+output_dir="${2:-${build_dir}/package}"
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "error: macOS DMG creation requires Darwin/macOS" >&2
+    exit 1
+fi
+
+major="$(awk '/^#define UAEMAJOR / { print $3; exit }' "${source_dir}/include/options.h")"
+minor="$(awk '/^#define UAEMINOR / { print $3; exit }' "${source_dir}/include/options.h")"
+revision="$(awk '/^#define UAESUBREV / { print $3; exit }' "${source_dir}/include/options.h")"
+version="${major:-0}.${minor:-0}.${revision:-0}"
+
+app_dir="$("${script_dir}/macos-bundle.sh" "${build_dir}" "${output_dir}" | awk 'NF { line = $0 } END { print line }')"
+staging_dir="${output_dir}/dmg-root"
+volume_name="WinUAE"
+rw_dmg="${output_dir}/WinUAE-${version}.rw.dmg"
+final_dmg="${output_dir}/WinUAE-${version}.dmg"
+mount_dir=""
+
+cleanup() {
+    if [[ -n "${mount_dir}" && -d "${mount_dir}" ]]; then
+        hdiutil detach "${mount_dir}" -quiet >/dev/null 2>&1 || true
+        rmdir "${mount_dir}" >/dev/null 2>&1 || true
+    fi
+}
+trap cleanup EXIT
+
+split_extra_args() {
+    if [[ -n "${1:-}" ]]; then
+        # Intentionally split user-provided extra flags the same way a shell would.
+        # shellcheck disable=SC2206
+        extra_args=($1)
+    else
+        extra_args=()
+    fi
+}
+
+rm -rf "${staging_dir}" "${rw_dmg}" "${final_dmg}"
+mkdir -p "${staging_dir}/.background"
+cp -R "${app_dir}" "${staging_dir}/WinUAE.app"
+ln -s /Applications "${staging_dir}/Applications"
+
+volume_icon="${app_dir}/Contents/Resources/WinUAE.icns"
+apply_volume_icon() {
+    local target_dir="$1"
+
+    if [[ ! -f "${volume_icon}" ]]; then
+        return
+    fi
+
+    cp "${volume_icon}" "${target_dir}/.VolumeIcon.icns"
+    if command -v SetFile >/dev/null 2>&1; then
+        SetFile -a C "${target_dir}" || true
+        SetFile -a V "${target_dir}/.VolumeIcon.icns" || true
+    fi
+}
+
+apply_volume_icon "${staging_dir}"
+
+background_tiff="${staging_dir}/.background/background.tiff"
+background_source="${source_dir}/od-unix/graphics/dmg_background.tiff"
+if [[ ! -f "${background_source}" ]]; then
+    echo "error: missing DMG background: ${background_source}" >&2
+    exit 1
+fi
+cp "${background_source}" "${background_tiff}"
+
+hdiutil create -volname "${volume_name}" -srcfolder "${staging_dir}" -fs HFS+ -format UDRW -ov "${rw_dmg}" >/dev/null
+mount_dir="$(hdiutil attach "${rw_dmg}" -readwrite -noverify -noautoopen | awk -F '\t' '/\/Volumes\// { print $NF; exit }')"
+if [[ -z "${mount_dir}" || ! -d "${mount_dir}" ]]; then
+    echo "error: failed to mount ${rw_dmg}" >&2
+    exit 1
+fi
+
+apply_volume_icon "${mount_dir}"
+
+require_finder_layout_records() {
+    local ds_store="$1"
+    local missing=0
+
+    for record in Iloc bwsp icvp; do
+        if ! LC_ALL=C grep -aq "${record}" "${ds_store}"; then
+            echo "error: Finder layout metadata is missing ${record} record in ${ds_store}" >&2
+            missing=1
+        fi
+    done
+
+    return "${missing}"
+}
+
+if ! command -v osascript >/dev/null 2>&1; then
+    echo "error: osascript is required to write the DMG Finder layout" >&2
+    exit 1
+fi
+
+osascript <<EOF
+tell application "Finder"
+    set dmgFolder to POSIX file "${mount_dir}" as alias
+    set backgroundPicture to POSIX file "${mount_dir}/.background/background.tiff" as alias
+    open dmgFolder
+    delay 5
+    set dmgWindow to container window of dmgFolder
+    set current view of dmgWindow to icon view
+    set viewOptions to icon view options of dmgWindow
+    set background picture of viewOptions to backgroundPicture
+    set arrangement of viewOptions to not arranged
+    set icon size of viewOptions to 96
+    update dmgFolder
+    delay 5
+    try
+        close dmgWindow
+    end try
+
+    open dmgFolder
+    delay 1
+    set dmgWindow to container window of dmgFolder
+    set current view of dmgWindow to icon view
+    try
+        set sidebar width of dmgWindow to 0
+    end try
+    try
+        set toolbar visible of dmgWindow to false
+    end try
+    try
+        set statusbar visible of dmgWindow to false
+    end try
+    set bounds of dmgWindow to {100, 100, 740, 500}
+    set viewOptions to icon view options of dmgWindow
+    set arrangement of viewOptions to not arranged
+    set icon size of viewOptions to 96
+    set background picture of viewOptions to backgroundPicture
+    set position of item "WinUAE.app" of dmgFolder to {178, 200}
+    set position of item "Applications" of dmgFolder to {462, 200}
+    update dmgFolder
+    delay 5
+    try
+        close dmgWindow
+    end try
+
+    open dmgFolder
+    delay 1
+    try
+        close container window of dmgFolder
+    end try
+end tell
+EOF
+for _ in {1..20}; do
+    if [[ -f "${mount_dir}/.DS_Store" ]] && require_finder_layout_records "${mount_dir}/.DS_Store" >/dev/null 2>&1; then
+        break
+    fi
+    sleep 0.5
+done
+if [[ ! -f "${mount_dir}/.DS_Store" ]]; then
+    echo "error: Finder did not write ${mount_dir}/.DS_Store; DMG background layout was not saved" >&2
+    exit 1
+fi
+require_finder_layout_records "${mount_dir}/.DS_Store"
+
+apply_volume_icon "${mount_dir}"
+if [[ -f "${volume_icon}" ]] && command -v SetFile >/dev/null 2>&1 && command -v GetFileInfo >/dev/null 2>&1; then
+    volume_attrs="$(GetFileInfo -a "${mount_dir}" 2>/dev/null || true)"
+    case "${volume_attrs}" in
+        *C*) ;;
+        *)
+            echo "error: custom volume icon attribute was not set on ${mount_dir}" >&2
+            exit 1
+            ;;
+    esac
+fi
+
+sync
+hdiutil detach "${mount_dir}" -quiet
+mount_dir=""
+hdiutil convert "${rw_dmg}" -format UDZO -imagekey zlib-level=9 -o "${final_dmg}" -ov >/dev/null
+
+dmg_codesign_identity="${WINUAE_DMG_CODESIGN_IDENTITY:-${WINUAE_CODESIGN_IDENTITY:-}}"
+if [[ -n "${dmg_codesign_identity}" && "${dmg_codesign_identity}" != "-" ]] && command -v codesign >/dev/null 2>&1; then
+    dmg_codesign_args=(--force --sign "${dmg_codesign_identity}")
+    split_extra_args "${WINUAE_DMG_CODESIGN_OPTIONS:-}"
+    dmg_codesign_args+=(${extra_args[@]+"${extra_args[@]}"})
+    codesign "${dmg_codesign_args[@]}" "${final_dmg}"
+fi
+
+hdiutil verify "${final_dmg}" >/dev/null
+
+if [[ "${WINUAE_SKIP_NOTARIZATION:-0}" != "1" && -n "${WINUAE_NOTARY_PROFILE:-}" ]]; then
+    if ! command -v xcrun >/dev/null 2>&1; then
+        echo "error: notarization requires xcrun/notarytool" >&2
+        exit 1
+    fi
+    xcrun notarytool submit "${final_dmg}" --keychain-profile "${WINUAE_NOTARY_PROFILE}" --wait
+    xcrun stapler staple "${final_dmg}"
+fi
+
+"${script_dir}/macos-verify-dmg.sh" "${final_dmg}" >/dev/null
+rm -rf "${rw_dmg}" "${staging_dir}"
+
+echo "${final_dmg}"
diff --git a/tools/macos-smoke-app.sh b/tools/macos-smoke-app.sh
new file mode 100755 (executable)
index 0000000..dd94595
--- /dev/null
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 app-path
+
+Launches a packaged WinUAE.app from an isolated HOME and verifies that the
+Qt configuration window appears. This is a release smoke test, not a normal
+build step.
+
+Environment:
+  WINUAE_MACOS_SMOKE_TIMEOUT       Seconds to wait. Defaults to 20.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+app_path="${1:-}"
+smoke_home=""
+smoke_log=""
+timeout_file=""
+launched=0
+
+cleanup() {
+    if [[ "${launched}" == "1" && -n "${bundle_id:-}" ]]; then
+        osascript - "${bundle_id}" <<'APPLESCRIPT' >/dev/null 2>&1 || true
+on run argv
+    tell application id (item 1 of argv) to quit
+end run
+APPLESCRIPT
+    fi
+    if [[ -n "${smoke_home}" && -d "${smoke_home}" ]]; then
+        rm -rf "${smoke_home}"
+    fi
+    if [[ -n "${smoke_log}" && -f "${smoke_log}" ]]; then
+        rm -f "${smoke_log}"
+    fi
+    if [[ -n "${timeout_file}" && -f "${timeout_file}" ]]; then
+        rm -f "${timeout_file}"
+    fi
+}
+trap cleanup EXIT
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "error: macOS app smoke testing requires Darwin/macOS" >&2
+    exit 1
+fi
+
+if [[ -z "${app_path}" ]]; then
+    usage >&2
+    exit 1
+fi
+
+if [[ -d "${app_path}/WinUAE.app" ]]; then
+    app_path="${app_path}/WinUAE.app"
+fi
+
+executable="${app_path}/Contents/MacOS/WinUAE"
+if [[ ! -x "${executable}" ]]; then
+    echo "error: app executable not found: ${executable}" >&2
+    exit 1
+fi
+
+bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${app_path}/Contents/Info.plist" 2>/dev/null || true)"
+if [[ -z "${bundle_id}" ]]; then
+    echo "error: CFBundleIdentifier missing from ${app_path}/Contents/Info.plist" >&2
+    exit 1
+fi
+
+timeout="${WINUAE_MACOS_SMOKE_TIMEOUT:-20}"
+smoke_home="$(mktemp -d -t winuae-app-smoke-home.XXXXXX)"
+smoke_log="$(mktemp -t winuae-app-smoke-log.XXXXXX)"
+timeout_file="$(mktemp -t winuae-app-smoke-timeout.XXXXXX)"
+rm -f "${timeout_file}"
+
+open -n -F -W \
+    --env "HOME=${smoke_home}" \
+    --env "WINUAE_MACOS_APP_SMOKE=1" \
+    -o "${smoke_log}" \
+    --stderr "${smoke_log}" \
+    "${app_path}" &
+open_pid="$!"
+launched=1
+
+(
+    sleep "${timeout}"
+    if kill -0 "${open_pid}" >/dev/null 2>&1; then
+        : > "${timeout_file}"
+        osascript - "${bundle_id}" <<'APPLESCRIPT' >/dev/null 2>&1 || true
+on run argv
+    tell application id (item 1 of argv) to quit
+end run
+APPLESCRIPT
+    fi
+) &
+watchdog_pid="$!"
+
+open_status=0
+wait "${open_pid}" || open_status="$?"
+kill "${watchdog_pid}" >/dev/null 2>&1 || true
+wait "${watchdog_pid}" >/dev/null 2>&1 || true
+
+if [[ -f "${timeout_file}" ]]; then
+    echo "error: timed out waiting for packaged Qt app smoke mode to finish" >&2
+    sed -n '1,120p' "${smoke_log}" >&2
+    exit 1
+fi
+if [[ "${open_status}" != "0" ]]; then
+    echo "error: packaged app launch failed with status ${open_status}" >&2
+    sed -n '1,120p' "${smoke_log}" >&2
+    exit 1
+fi
+if ! grep -q '^WINUAE_QT_SMOKE_WINDOW_VISIBLE$' "${smoke_log}"; then
+    echo "error: packaged app did not report a visible Qt configuration window" >&2
+    sed -n '1,120p' "${smoke_log}" >&2
+    exit 1
+fi
+
+echo "verified packaged Qt app launch: ${app_path}"
diff --git a/tools/macos-verify-dmg.sh b/tools/macos-verify-dmg.sh
new file mode 100755 (executable)
index 0000000..575e5c0
--- /dev/null
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<EOF
+Usage: $0 dmg-path
+
+Verifies a WinUAE macOS drag-install DMG.
+
+The check mounts the image, validates the app/layout resources, and runs the
+bundled executable with -h from an isolated HOME to catch missing runtime
+libraries without starting emulation.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+    usage
+    exit 0
+fi
+
+dmg_path="${1:-}"
+mount_dir=""
+launch_home=""
+launch_log=""
+
+cleanup() {
+    if [[ -n "${launch_home}" && -d "${launch_home}" ]]; then
+        rm -rf "${launch_home}"
+    fi
+    if [[ -n "${launch_log}" && -f "${launch_log}" ]]; then
+        rm -f "${launch_log}"
+    fi
+    if [[ -n "${mount_dir}" && -d "${mount_dir}" ]]; then
+        hdiutil detach "${mount_dir}" -quiet >/dev/null 2>&1 || true
+    fi
+}
+trap cleanup EXIT
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "error: macOS DMG verification requires Darwin/macOS" >&2
+    exit 1
+fi
+
+if [[ -z "${dmg_path}" ]]; then
+    usage >&2
+    exit 1
+fi
+
+if [[ ! -f "${dmg_path}" ]]; then
+    echo "error: DMG not found: ${dmg_path}" >&2
+    exit 1
+fi
+
+hdiutil verify "${dmg_path}" >/dev/null
+mount_dir="$(hdiutil attach "${dmg_path}" -readonly -noverify -noautoopen | awk -F '\t' '/\/Volumes\// { print $NF; exit }')"
+if [[ -z "${mount_dir}" || ! -d "${mount_dir}" ]]; then
+    echo "error: failed to mount ${dmg_path}" >&2
+    exit 1
+fi
+
+app_dir="${mount_dir}/WinUAE.app"
+info_plist="${app_dir}/Contents/Info.plist"
+
+require_path() {
+    local path="$1"
+    local description="$2"
+    if [[ ! -e "${path}" ]]; then
+        echo "error: missing ${description}: ${path}" >&2
+        exit 1
+    fi
+}
+
+require_file() {
+    local path="$1"
+    local description="$2"
+    if [[ ! -f "${path}" ]]; then
+        echo "error: missing ${description}: ${path}" >&2
+        exit 1
+    fi
+}
+
+require_path "${app_dir}" "app bundle"
+require_file "${info_plist}" "Info.plist"
+require_file "${app_dir}/Contents/MacOS/WinUAE" "bundle executable"
+if [[ ! -x "${app_dir}/Contents/MacOS/WinUAE" ]]; then
+    echo "error: bundle executable is not executable: ${app_dir}/Contents/MacOS/WinUAE" >&2
+    exit 1
+fi
+require_file "${app_dir}/Contents/Resources/WinUAE.icns" "application icon"
+require_file "${app_dir}/Contents/Resources/README_unix.md" "bundled README"
+
+if [[ ! -L "${mount_dir}/Applications" ]]; then
+    echo "error: missing /Applications symlink" >&2
+    exit 1
+fi
+if [[ "$(readlink "${mount_dir}/Applications")" != "/Applications" ]]; then
+    echo "error: Applications symlink does not point to /Applications" >&2
+    exit 1
+fi
+
+require_file "${mount_dir}/.DS_Store" "Finder layout metadata"
+require_file "${mount_dir}/.background/background.tiff" "Finder background image"
+require_file "${mount_dir}/.VolumeIcon.icns" "volume icon"
+
+for record in Iloc bwsp icvp; do
+    if ! LC_ALL=C grep -aq "${record}" "${mount_dir}/.DS_Store"; then
+        echo "error: Finder layout metadata is missing ${record} record in ${mount_dir}/.DS_Store" >&2
+        exit 1
+    fi
+done
+
+if command -v GetFileInfo >/dev/null 2>&1; then
+    volume_attrs="$(GetFileInfo -a "${mount_dir}" 2>/dev/null || true)"
+    case "${volume_attrs}" in
+        *C*) ;;
+        *)
+            echo "error: custom volume icon attribute is not set on ${mount_dir}" >&2
+            exit 1
+            ;;
+    esac
+fi
+
+plist_get() {
+    /usr/libexec/PlistBuddy -c "Print $1" "${info_plist}" 2>/dev/null || true
+}
+
+if [[ "$(plist_get ':CFBundleExecutable')" != "WinUAE" ]]; then
+    echo "error: CFBundleExecutable is not WinUAE" >&2
+    exit 1
+fi
+if [[ "$(plist_get ':CFBundleIconFile')" != "WinUAE.icns" ]]; then
+    echo "error: CFBundleIconFile is not WinUAE.icns" >&2
+    exit 1
+fi
+if [[ "$(plist_get ':CFBundleDocumentTypes:0:CFBundleTypeExtensions:0')" != "uae" ]]; then
+    echo "error: .uae document type is not registered in Info.plist" >&2
+    exit 1
+fi
+
+launch_home="$(mktemp -d -t winuae-dmg-home.XXXXXX)"
+launch_log="$(mktemp -t winuae-dmg-launch.XXXXXX)"
+if ! HOME="${launch_home}" QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy "${app_dir}/Contents/MacOS/WinUAE" -h >"${launch_log}" 2>&1; then
+    echo "error: bundled executable did not start successfully from the mounted DMG" >&2
+    sed -n '1,120p' "${launch_log}" >&2
+    exit 1
+fi
+
+echo "verified ${dmg_path}"