diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml new file mode 100644 index 00000000..bfa18ac2 --- /dev/null +++ b/.github/workflows/macos-build.yml @@ -0,0 +1,31 @@ +name: macOS build + +on: + push: + branches: [master, feature/apple-silicon-macos] + tags: ['v*'] + pull_request: + branches: [master] + +jobs: + build: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: brew install qt go + + - name: Build + run: ./scripts/macos/build-local.sh + + - name: Verify architecture + run: | + file build/Moolticute.app/Contents/MacOS/moolticute + lipo -info build/Moolticute.app/Contents/MacOS/moolticute + + - name: Run tests + working-directory: build + run: ./tests/tests diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 00000000..d2c49ba6 --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -0,0 +1,69 @@ +name: macOS release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g. v1.04.0-arm64)' + required: true + default: 'v1.04.0-arm64' + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Install dependencies + run: brew install qt go + + - name: Build and package app + run: | + ./scripts/macos/build-local.sh --package + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + ./scripts/macos/package-release.sh "${{ github.event.inputs.version }}" + else + ./scripts/macos/package-release.sh "${GITHUB_REF_NAME}" + fi + + - name: Run tests + working-directory: build + run: ./tests/tests + + - name: Verify native architecture + run: | + file build/Moolticute.app/Contents/MacOS/moolticute + lipo -info build/Moolticute.app/Contents/MacOS/moolticute + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} + name: Moolticute macOS Apple Silicon (${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}) + body: | + Native **arm64** Moolticute build for Apple Silicon Macs (no Rosetta required). + + ### Install + 1. Download `Moolticute-*-macos-arm64.dmg` or `.zip` + 2. Open the DMG and drag **Moolticute** to Applications + 3. On first launch, right-click → **Open** if macOS blocks an unsigned app + + ### Includes + - Moolticute GUI + `moolticuted` daemon + - `mc-agent` and `mc-cli` (arm64) + + Built from commit ${{ github.sha }}. + files: | + build/Moolticute-*-macos-*.dmg + build/Moolticute-*-macos-*.zip + draft: false + prerelease: true diff --git a/README.md b/README.md index a962daba..bfee9fbc 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,21 @@ git clone https://github.com/mooltipass/moolticute.git --recurse-submodules - qt-network - qt-websockets +##### macOS (Apple Silicon) + +Official release DMGs are currently built for Intel (`x86_64`). To run natively on Apple Silicon without Rosetta, build from source: + +```bash +brew install qt go +git clone --recurse-submodules https://github.com/mooltipass/moolticute.git +cd moolticute +./scripts/macos/build-local.sh --package +``` + +See [documentation/macos_apple_silicon.md](documentation/macos_apple_silicon.md) for details. + +**Community Apple Silicon builds** (unsigned): [GitHub Releases](https://github.com/poitee/moolticute/releases) on the `poitee/moolticute` fork. + ##### Linux - Requires the qt-dbus module - Requires to install [udev rule](https://github.com/mooltipass/mooltipass-udev) for it diff --git a/documentation/macos_apple_silicon.md b/documentation/macos_apple_silicon.md new file mode 100644 index 00000000..70093287 --- /dev/null +++ b/documentation/macos_apple_silicon.md @@ -0,0 +1,37 @@ +# Building Moolticute on macOS (Apple Silicon) + +Official release DMGs are currently built for Intel (`x86_64`). On Apple Silicon Macs the app runs through Rosetta today, but native `arm64` builds are supported from source. + +## Quick build + +```bash +brew install qt go +git clone --recurse-submodules https://github.com/mooltipass/moolticute.git +cd moolticute +./scripts/macos/build-local.sh +open build/Moolticute.app +``` + +To bundle the daemon and CLI tools into the app: + +```bash +./scripts/macos/build-local.sh --package +``` + +## What changes on Apple Silicon + +- Qt is taken from Homebrew (`/opt/homebrew/opt/qt`) when available. +- `mc-agent` and `mc-cli` are compiled from source because the prebuilt Intel binaries hosted for releases are not compatible with `arm64`. +- The resulting `Moolticute.app` runs natively without Rosetta. + +## Verify the build + +```bash +file build/Moolticute.app/Contents/MacOS/moolticute +# Expected on Apple Silicon: Mach-O 64-bit executable arm64 +``` + +## Related issues + +- https://github.com/mooltipass/moolticute/issues/1254 +- https://github.com/mooltipass/moolticute/issues/929 diff --git a/scripts/ci/osx/after_success.sh b/scripts/ci/osx/after_success.sh index ecd5c4b2..0a23a341 100755 --- a/scripts/ci/osx/after_success.sh +++ b/scripts/ci/osx/after_success.sh @@ -8,6 +8,7 @@ set -ev SCRIPTDIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) source $SCRIPTDIR/../funcs.sh +source $SCRIPTDIR/env.sh VERSION="$(get_version .)" @@ -17,7 +18,8 @@ if [ "$(git rev-list -n 1 $VERSION)" != "$(git rev-parse HEAD)" ]; then exit 0 fi -QTDIR="/Users/travis/Qt/6.2.4/macos" +detect_qtdir +MACDEPLOYQT_BIN="$(macdeployqt_bin)" APP=Moolticute # this directory name will also be shown in the title when the DMG is mounted TEMPDIR=build/$APP @@ -37,15 +39,13 @@ cat build/$APP.app/Contents/Info.plist # Copy daemon to bundle cp build/moolticuted build/$APP.app/Contents/MacOS/ -#Get 3rd party tools +# Bundle architecture-matching CLI tools mkdir -p build/$APP.app/Contents/MacOS/cli -wget_retry https://calaos.fr/mooltipass/tools/macos/mc-agent -O build/$APP.app/Contents/MacOS/cli/mc-agent -wget_retry https://calaos.fr/mooltipass/tools/macos/mc-cli -O build/$APP.app/Contents/MacOS/cli/mc-cli -chmod +x build/$APP.app/Contents/MacOS/cli/mc-agent build/$APP.app/Contents/MacOS/cli/mc-cli +bundle_mc_cli_tools build/$APP.app/Contents/MacOS/cli # use macdeployqt to deploy the application echo "Calling macdeployqt" -$QTDIR/bin/macdeployqt build/$APP.app +$MACDEPLOYQT_BIN build/$APP.app if [ "$?" -ne "0" ]; then echo "Failed to run macdeployqt" exit 1 diff --git a/scripts/ci/osx/env.sh b/scripts/ci/osx/env.sh new file mode 100755 index 00000000..4a7bc793 --- /dev/null +++ b/scripts/ci/osx/env.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Shared macOS build environment for Intel and Apple Silicon hosts. + +set -e + +OSX_SCRIPTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +export MACOS_ARCH="$(uname -m)" + +case "$MACOS_ARCH" in + arm64) + export GOARCH=arm64 + export MC_CLI_TOOLS_ARCH=arm64 + ;; + x86_64) + export GOARCH=amd64 + export MC_CLI_TOOLS_ARCH=amd64 + ;; + *) + echo "Unsupported macOS architecture: $MACOS_ARCH" + exit 1 + ;; +esac + +detect_qtdir() { + if [ -n "${QTDIR:-}" ] && { [ -x "$QTDIR/bin/qmake" ] || [ -x "$QTDIR/bin/qmake6" ]; }; then + return 0 + fi + + for candidate in \ + "/opt/homebrew/opt/qt" \ + "/usr/local/opt/qt" \ + "/Users/travis/Qt/6.2.4/macos" \ + "$HOME/Qt/6.2.4/macos" + do + if [ -x "$candidate/bin/qmake" ] || [ -x "$candidate/bin/qmake6" ]; then + export QTDIR="$candidate" + return 0 + fi + done + + echo "Qt not found. Install with Homebrew (brew install qt) or set QTDIR." + return 1 +} + +qmake_bin() { + if [ -x "$QTDIR/bin/qmake6" ]; then + echo "$QTDIR/bin/qmake6" + else + echo "$QTDIR/bin/qmake" + fi +} + +macdeployqt_bin() { + if [ -x "$QTDIR/bin/macdeployqt6" ]; then + echo "$QTDIR/bin/macdeployqt6" + else + echo "$QTDIR/bin/macdeployqt" + fi +} + +build_mc_cli_tools() { + local dest="$1" + + if ! command -v go >/dev/null 2>&1; then + echo "Go is required to build mc-agent and mc-cli for $MACOS_ARCH" + return 1 + fi + + mkdir -p "$dest" + local tmp + tmp="$(mktemp -d)" + + for tool in mc-agent mc-cli; do + rm -rf "$tmp/$tool" + git clone --depth 1 "https://github.com/raoulh/$tool.git" "$tmp/$tool" + ( + cd "$tmp/$tool" + GOOS=darwin GOARCH="$GOARCH" go build -o "$dest/$tool" . + ) || return 1 + [ -f "$dest/$tool" ] || return 1 + chmod +x "$dest/$tool" + done + + rm -rf "$tmp" +} + +bundle_mc_cli_tools() { + local dest="$1" + + if [ "$MC_CLI_TOOLS_ARCH" = "amd64" ]; then + # shellcheck source=../funcs.sh + source "$OSX_SCRIPTDIR/../funcs.sh" + wget_retry "https://calaos.fr/mooltipass/tools/macos/mc-agent" -O "$dest/mc-agent" + wget_retry "https://calaos.fr/mooltipass/tools/macos/mc-cli" -O "$dest/mc-cli" + chmod +x "$dest/mc-agent" "$dest/mc-cli" + return 0 + fi + + build_mc_cli_tools "$dest" +} diff --git a/scripts/ci/osx/install.sh b/scripts/ci/osx/install.sh index 39566fde..b5551538 100755 --- a/scripts/ci/osx/install.sh +++ b/scripts/ci/osx/install.sh @@ -4,14 +4,25 @@ set -ev echo "install.sh" brew upgrade openssl -brew install jq lftp -brew uninstall wget +brew install jq lftp go +brew uninstall wget || true brew install wget + +SCRIPTDIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +source $SCRIPTDIR/env.sh + echo "Current PATH: $PATH" -#pip3 install --break-system-packages --upgrade pip -#pip3 install --break-system-packages aqtinstall -t /Users/travis/aqt -pip3 install --upgrade pip -pip3 install aqtinstall -t /Users/travis/aqt -export PYTHONPATH=$PYTHONPATH:/Users/travis/aqt -/Users/travis/aqt/bin/aqt install-qt mac desktop 6.2.4 clang_64 -m all -O /Users/travis/Qt -networksetup -setv6off Ethernet +echo "Target architecture: $MACOS_ARCH" + +if [ "$MACOS_ARCH" = "arm64" ]; then + brew install qt + detect_qtdir + echo "Using Homebrew Qt at $QTDIR for Apple Silicon" +else + pip3 install --upgrade pip + pip3 install aqtinstall -t /Users/travis/aqt + export PYTHONPATH=$PYTHONPATH:/Users/travis/aqt + /Users/travis/aqt/bin/aqt install-qt mac desktop 6.2.4 clang_64 -m all -O /Users/travis/Qt + export QTDIR="/Users/travis/Qt/6.2.4/macos" + networksetup -setv6off Ethernet || true +fi diff --git a/scripts/ci/osx/script.sh b/scripts/ci/osx/script.sh index b6f6d883..b45a33f9 100755 --- a/scripts/ci/osx/script.sh +++ b/scripts/ci/osx/script.sh @@ -2,19 +2,20 @@ set -ev SCRIPTDIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) -source $SCRIPTDIR/../funcs.sh +source $SCRIPTDIR/env.sh mkdir build pushd build -QTDIR="/Users/travis/Qt/6.2.4/macos" +detect_qtdir +QMAKE_BIN="$(qmake_bin)" PATH="$QTDIR/bin:$PATH" LDFLAGS=-L$QTDIR/lib CPPFLAGS=-I$QTDIR/include make_version .. macos -qmake ../Moolticute.pro +"$QMAKE_BIN" ../Moolticute.pro # Compund exit codes of make and the tests. make && ./tests/tests diff --git a/scripts/macos/build-local.sh b/scripts/macos/build-local.sh new file mode 100755 index 00000000..cd8dd5f0 --- /dev/null +++ b/scripts/macos/build-local.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Build Moolticute natively on macOS (Intel or Apple Silicon). +# +# Requirements: +# brew install qt go +# +# Usage: +# ./scripts/macos/build-local.sh +# ./scripts/macos/build-local.sh --package + +set -eo pipefail + +SCRIPTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPTDIR/../.." && pwd)" +PACKAGE=0 + +while [ $# -gt 0 ]; do + case "$1" in + --package) PACKAGE=1 ;; + -h|--help) + echo "Usage: $0 [--package]" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac + shift +done + +# shellcheck source=../ci/osx/env.sh +source "$SCRIPTDIR/../ci/osx/env.sh" +# shellcheck source=../ci/funcs.sh +source "$SCRIPTDIR/../ci/funcs.sh" + +detect_qtdir +QMAKE_BIN="$(qmake_bin)" +MACDEPLOYQT_BIN="$(macdeployqt_bin)" + +echo "Building Moolticute for $MACOS_ARCH using Qt at $QTDIR" + +cd "$REPO_ROOT" +make_version . macos + +mkdir -p build +cd build + +export PATH="$QTDIR/bin:$PATH" +"$QMAKE_BIN" ../Moolticute.pro +make -j"$(sysctl -n hw.ncpu)" + +if [ "$PACKAGE" -eq 0 ]; then + echo "Build complete: $REPO_ROOT/build/Moolticute.app" + file "$REPO_ROOT/build/Moolticute.app/Contents/MacOS/moolticute" + exit 0 +fi + +APP=Moolticute +cp "$SCRIPTDIR/../ci/osx/Info.plist" "$APP.app/Contents/Info.plist" +cp moolticuted "$APP.app/Contents/MacOS/" + +mkdir -p "$APP.app/Contents/MacOS/cli" +bundle_mc_cli_tools "$REPO_ROOT/build/$APP.app/Contents/MacOS/cli" + +"$MACDEPLOYQT_BIN" "$APP.app" + +echo "Packaged app: $REPO_ROOT/build/$APP.app" +file "$APP.app/Contents/MacOS/moolticute" "$APP.app/Contents/MacOS/cli/mc-agent" diff --git a/scripts/macos/package-release.sh b/scripts/macos/package-release.sh new file mode 100755 index 00000000..0f6bb0d4 --- /dev/null +++ b/scripts/macos/package-release.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Build a distributable macOS .dmg and .zip from build/Moolticute.app +# +# Usage: ./scripts/macos/package-release.sh [version] +# Example: ./scripts/macos/package-release.sh v1.04.0-arm64 + +set -eo pipefail + +SCRIPTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPTDIR/../.." && pwd)" +APP=Moolticute +ARCH="$(uname -m)" + +if [ -n "${1:-}" ]; then + VERSION="$1" +else + VERSION="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 2>/dev/null || echo "dev")" +fi +VERSION="${VERSION#v}" + +APP_PATH="$REPO_ROOT/build/${APP}.app" +if [ ! -d "$APP_PATH" ]; then + echo "Missing $APP_PATH — run ./scripts/macos/build-local.sh --package first" + exit 1 +fi + +# Ad-hoc sign so local Gatekeeper may allow after user approval (not notarized). +codesign --force --deep --sign - "$APP_PATH" 2>/dev/null || true + +BASENAME="${APP}-${VERSION}-macos-${ARCH}" +DMG_PATH="$REPO_ROOT/build/${BASENAME}.dmg" +ZIP_PATH="$REPO_ROOT/build/${BASENAME}.zip" + +rm -f "$DMG_PATH" "$ZIP_PATH" + +echo "Creating $DMG_PATH" +hdiutil create \ + -volname "$APP" \ + -srcfolder "$APP_PATH" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +echo "Creating $ZIP_PATH" +ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_PATH" + +echo "Artifacts:" +ls -lh "$DMG_PATH" "$ZIP_PATH" +file "$APP_PATH/Contents/MacOS/moolticute"