diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 00000000..7ff47a98 --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,177 @@ +name: build-macos +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + workflow_call: + +jobs: + # Builds one bundle per architecture. They are merged into a single universal + # bundle by the job below, so nothing is signed here: lipo would invalidate the + # signature anyway. + build-macos: + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + # opencv@4 rather than opencv, which is now OpenCV 5, to stay on the same + # major version as the Linux and Windows builds. + - name: Install dependencies + run: brew install qt qwt opencv@4 armadillo + + # Homebrew is in /usr/local on Intel and /opt/homebrew on Apple silicon, + # and Qt6 is split across several kegs. + - name: Resolve Homebrew layout + run: | + QT_PREFIX="$(brew --prefix qt)" + if [ ! -x "$QT_PREFIX/bin/qmake" ]; then QT_PREFIX="$(brew --prefix qtbase)"; fi + echo "QT_PREFIX=$QT_PREFIX" >> "$GITHUB_ENV" + echo "PKG_CONFIG_PATH=$(brew --prefix qwt)/lib/pkgconfig:$(brew --prefix opencv@4)/lib/pkgconfig:$(brew --prefix armadillo)/lib/pkgconfig:$(brew --prefix)/lib/pkgconfig" >> "$GITHUB_ENV" + + # Same naming as build-windows.yml. + - name: Resolve version strings + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + WORKFLOW_VERSION="${{ github.event.pull_request.head.sha }}_${{ github.event.pull_request.base.sha }}" + elif [ "${{ startsWith(github.ref, 'refs/tags/v') }}" = "true" ]; then + WORKFLOW_VERSION="${{ github.ref_name }}" + else + WORKFLOW_VERSION="${{ github.sha }}" + fi + # CFBundleShortVersionString only accepts a dotted number, so a commit + # sha cannot go there. + BUNDLE_VERSION="$(printf '%s' "$WORKFLOW_VERSION" | sed -nE 's/^v?([0-9]+\.[0-9]+\.[0-9]+).*$/\1/p')" + if [ -z "$BUNDLE_VERSION" ]; then BUNDLE_VERSION="0.0.0"; fi + echo "WORKFLOW_VERSION=$WORKFLOW_VERSION" >> "$GITHUB_ENV" + echo "BUNDLE_VERSION=$BUNDLE_VERSION" >> "$GITHUB_ENV" + + - name: Find and Replace MY_AUTOMATED_VERSION_STRING + run: sed -i '' "s/MY_AUTOMATED_VERSION_STRING/${WORKFLOW_VERSION}/" DFTFringe.pro + + - name: Configure + run: | + "$QT_PREFIX/bin/qmake" DFTFringe.pro CONFIG+=release + + - uses: ammaraskar/gcc-problem-matcher@master + - run: echo "::add-matcher::.github/matcher/uic_matcher.json" + - name: Build + run: make -j$(sysctl -n hw.ncpu) + - run: echo "::remove-matcher owner=uic-problem-matcher::" + + - name: Bundle dependencies + run: | + "$QT_PREFIX/bin/macdeployqt" build/release/DFTFringe.app -verbose=1 + # Contents/MacOS may hold nothing but code or codesign rejects the + # bundle. colormapviewerdlg falls back to Contents/Resources on macOS. + cp -R ColorMaps build/release/DFTFringe.app/Contents/Resources/ + # qmake only templates the version keys when VERSION is a dotted + # number, so for untagged builds they may be absent rather than wrong. + PLIST=build/release/DFTFringe.app/Contents/Info.plist + for key in CFBundleShortVersionString CFBundleVersion; do + /usr/libexec/PlistBuddy -c "Set :$key $BUNDLE_VERSION" "$PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :$key string $BUNDLE_VERSION" "$PLIST" + done + + - name: Verify the bundle is self contained + run: | + otool -L build/release/DFTFringe.app/Contents/MacOS/DFTFringe \ + | tail -n +2 | awk '{print $1}' \ + | grep -vE "^(@rpath|@executable_path|/usr/lib|/System)" \ + && { echo "bundle references paths outside itself"; exit 1; } || true + + # Tarred because upload-artifact does not preserve the symlinks inside the + # Qt frameworks. + - name: Upload bundle + run: tar czf DFTFringe-${{ matrix.arch }}.tar.gz -C build/release DFTFringe.app + - uses: actions/upload-artifact@v7 + with: + name: DFTFringe-macos-${{ matrix.arch }}-bundle + path: DFTFringe-${{ matrix.arch }}.tar.gz + retention-days: 1 + + # Merges the two bundles into one universal application, signs it and wraps it + # in a disk image. Users get a single download that runs on both architectures. + universal-dmg: + needs: build-macos + runs-on: macos-15 + steps: + - uses: actions/download-artifact@v8 + with: + pattern: DFTFringe-macos-*-bundle + merge-multiple: true + + - name: Resolve version strings + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + WORKFLOW_VERSION="${{ github.event.pull_request.head.sha }}_${{ github.event.pull_request.base.sha }}" + elif [ "${{ startsWith(github.ref, 'refs/tags/v') }}" = "true" ]; then + WORKFLOW_VERSION="${{ github.ref_name }}" + else + WORKFLOW_VERSION="${{ github.sha }}" + fi + echo "WORKFLOW_VERSION=$WORKFLOW_VERSION" >> "$GITHUB_ENV" + + - name: Merge the two architectures + run: | + mkdir arm64 x86_64 + tar xzf DFTFringe-arm64.tar.gz -C arm64 + tar xzf DFTFringe-x86_64.tar.gz -C x86_64 + ARM="$PWD/arm64/DFTFringe.app" + INTEL="$PWD/x86_64/DFTFringe.app" + + # Homebrew resolves formulae independently on the two runners, so a + # version bump landing between the jobs would give the bundles + # different contents. Merging those silently would ship an + # application half built against two different sets of libraries. + diff <(cd "$ARM" && find . | sort) <(cd "$INTEL" && find . | sort) \ + || { echo "the two bundles do not contain the same files"; exit 1; } + + cp -R "$ARM" DFTFringe.app + UNIVERSAL="$PWD/DFTFringe.app" + find "$UNIVERSAL" -type f | while read -r f; do + rel="${f#$UNIVERSAL/}" + if file -b "$f" | grep -q "Mach-O"; then + lipo -create "$ARM/$rel" "$INTEL/$rel" -output "$f" + fi + done + + echo "--- architectures in the merged executable:" + lipo -info "$UNIVERSAL/Contents/MacOS/DFTFringe" + + # Signed inside out rather than with --deep, which Apple deprecates for + # signing. arm64 code has to carry at least an ad-hoc signature to run at + # all, and lipo invalidated whatever was there before. + - name: Ad-hoc sign + run: | + APP=DFTFringe.app + find "$APP/Contents/Frameworks" "$APP/Contents/PlugIns" \ + \( -name "*.dylib" -o -name "*.so" \) \ + -exec codesign --force --sign - --timestamp=none {} \; 2>/dev/null || true + find "$APP/Contents/Frameworks" -maxdepth 1 -name "*.framework" \ + -exec codesign --force --sign - --timestamp=none {} \; 2>/dev/null || true + codesign --force --sign - --timestamp=none "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" + + - name: Create disk image + run: | + STAGE="$(mktemp -d)/DFTFringe" + mkdir -p "$STAGE" + cp -R DFTFringe.app "$STAGE/" + ln -s /Applications "$STAGE/Applications" + hdiutil create -volname "DFTFringe" -srcfolder "$STAGE" -ov -format UDZO \ + "DFTFringe-${WORKFLOW_VERSION}.dmg" + + - uses: actions/upload-artifact@v7 + with: + name: DFTFringe-macos-build-artifact + path: DFTFringe-${{ env.WORKFLOW_VERSION }}.dmg diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 8830af96..a9c228f1 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -30,6 +30,10 @@ jobs: call-build-windows: needs: check-semver uses: ./.github/workflows/build-windows.yml + # one disk image per architecture, see build-macos.yml + call-build-macos: + needs: check-semver + uses: ./.github/workflows/build-macos.yml # linux build is mainly here to check it builds. We have no acrtifact now. call-build-linux: permissions: @@ -40,12 +44,15 @@ jobs: download-and-publish-artifacts: runs-on: ubuntu-latest - needs: call-build-windows + needs: [call-build-windows, call-build-macos] steps: # get artifact uploaded from build workflow - uses: actions/download-artifact@v8 with: name: DFTFringe-windows-build-artifact + - uses: actions/download-artifact@v8 + with: + name: DFTFringe-macos-build-artifact # create the GitHub release and upload the artifacts - name: publish Release uses: softprops/action-gh-release@v3 @@ -53,6 +60,8 @@ jobs: body: | - edit this changelog - test the installer one last time + - the macOS disk image is not notarised, so the first launch needs + the quarantine flag cleared. See the README for details. - make the actual release from this draft # the release will be drafted so it needs to be manually published after release notes editions draft: true @@ -61,3 +70,4 @@ jobs: files: | DFTFringeInstaller_${{github.ref_name}}.exe Z_DFTFringe.exe.debug + DFTFringe-${{github.ref_name}}.dmg diff --git a/DFTFringe.pro b/DFTFringe.pro index e94e9cab..d587f288 100644 --- a/DFTFringe.pro +++ b/DFTFringe.pro @@ -96,11 +96,10 @@ macx { CONFIG += app_bundle CONFIG += sdk_no_version_check CONFIG += link_pkgconfig - CONFIG += silent - QMAKE_FULL_VERSION=APP_VERSION - QMAKE_MACOSX_DEPLOYMENT_TARGET = 11.0 - QMAKE_APPLE_DEVICE_ARCHS = x86_64 arm64 + # Homebrew libraries are single architecture, so each build targets its host. + # The CI merges an arm64 and an x86_64 build into one universal application. + QMAKE_APPLE_DEVICE_ARCHS = $$QMAKE_HOST.arch CONFIG( debug, debug|release ) { DESTDIR = build/debug } CONFIG( release, debug|release ) { DESTDIR = build/release } @@ -109,32 +108,41 @@ macx { OBJECTS_DIR = $$DESTDIR/.obj #these change between build and release. RCC_DIR = $$DESTDIR/.qrc UI_DIR = $$DESTDIR/.ui - QMAKE_MKDIR = /usr/local/bin/mkdir # This tells QMAKE which mkdir command to use. - QMAKE_PKG_CONFIG = /opt/homebrew/bin/pkg-config # This tells QMAKE which pkg-config executable to use. - PKG_CONFIG_PATH = $$[QT_INSTALL_LIBS]/pkgconfig - INCLUDEPATH += -I$$[QT_INSTALL_PLUGINS] - LIBS += -L$$[QT_INSTALL_PLUGINS] - PKGCONFIG += armadillo opencv Qt5Qwt6 - - message(........QT_VERSION: $$[QT_VERSION]) - message(.QT_INSTALL_PREFIX: $$[QT_INSTALL_PREFIX]) - message(QT_INSTALL_HEADERS: $$[QT_INSTALL_HEADERS]) - message(...QT_INSTALL_LIBS: $$[QT_INSTALL_LIBS]) - message(QT_INSTALL_PLUGINS: $$[QT_INSTALL_PLUGINS]) - message(...................) - message(...........DESTDIR: $$DESTDIR) - message(...........MOC_DIR: $$MOC_DIR) - message(.......OBJECTS_DIR: $$OBJECTS_DIR) - message(...........RCC_DIR: $$RCC_DIR) - message(............UI_DIR: $$UI_DIR) - message(...................) - message(.......QMAKE_MKDIR: $$QMAKE_MKDIR) - message(..QMAKE_PKG_CONFIG: $$QMAKE_PKG_CONFIG) - message(...PKG_CONFIG_PATH: $$PKG_CONFIG_PATH) - message(.......INCLUDEPATH: $$INCLUDEPATH) - message(..............LIBS: $$LIBS) - message(.........PKGCONFIG: $$PKGCONFIG) - message(............CONFIG: $$CONFIG) + + # pkg-config keeps the Homebrew prefix out of this file: it is /usr/local on + # Intel and /opt/homebrew on Apple silicon. See the README for the + # PKG_CONFIG_PATH the kegs need. + PKGCONFIG += armadillo Qt6Qwt6 + + # Homebrew's opencv formula is OpenCV 5 now. The CI installs opencv@4 to match + # the Linux and Windows builds, but a local checkout may only have OpenCV 5. + packagesExist(opencv4) { + OPENCV_PACKAGE = opencv4 + } else { + OPENCV_PACKAGE = opencv5 + } + + # Not PKGCONFIG: the .pc file links every OpenCV module, which drags dnn, gapi + # and OpenVINO into the bundle. Link the six the other platforms use. + QMAKE_CXXFLAGS += $$system(pkg-config --cflags-only-I $$OPENCV_PACKAGE) + LIBS += $$system(pkg-config --libs-only-L $$OPENCV_PACKAGE) + LIBS += -lopencv_calib3d + LIBS += -lopencv_core + LIBS += -lopencv_features2d + LIBS += -lopencv_highgui + LIBS += -lopencv_imgcodecs + LIBS += -lopencv_imgproc + + # Homebrew builds qwt as a framework, so its headers are inside the bundle and + # not in the include directory its pkg-config file advertises. + QWT_FRAMEWORK_HEADERS = $$system(pkg-config --variable=libdir Qt6Qwt6)/qwt.framework/Headers + exists($$QWT_FRAMEWORK_HEADERS): INCLUDEPATH += $$QWT_FRAMEWORK_HEADERS + + LIBS += -lz # zip compression library needed for cnpy.cpp + + # Boost.Stacktrace guards _Unwind_Backtrace behind _GNU_SOURCE, a glibc + # convention. On macOS it comes from Apple's libunwind and needs no define. + DEFINES += BOOST_STACKTRACE_GNU_SOURCE_NOT_REQUIRED } # Below are the includes for source files and other resources, sorted alphabetically. ################################## diff --git a/README.md b/README.md index d67cdcc8..583ae57d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DFTFringe -[![build-windows](https://github.com/githubdoe/DFTFringe/actions/workflows/build-windows.yml/badge.svg?branch=master)](https://github.com/githubdoe/DFTFringe/actions/workflows/build-windows.yml) [![build-linux](https://github.com/githubdoe/DFTFringe/actions/workflows/build-linux.yml/badge.svg?branch=master)](https://github.com/githubdoe/DFTFringe/actions/workflows/build-linux.yml) +[![build-windows](https://github.com/githubdoe/DFTFringe/actions/workflows/build-windows.yml/badge.svg?branch=master)](https://github.com/githubdoe/DFTFringe/actions/workflows/build-windows.yml) [![build-linux](https://github.com/githubdoe/DFTFringe/actions/workflows/build-linux.yml/badge.svg?branch=master)](https://github.com/githubdoe/DFTFringe/actions/workflows/build-linux.yml) [![build-macos](https://github.com/githubdoe/DFTFringe/actions/workflows/build-macos.yml/badge.svg?branch=master)](https://github.com/githubdoe/DFTFringe/actions/workflows/build-macos.yml) # Introduction @@ -50,9 +50,57 @@ cd .. make -j4 ``` +# How to install DFTFringe on MacOS + +Download the disk image from the +[latest release](https://github.com/githubdoe/dftfringe/releases/latest), open it +and drag DFTFringe into Applications. The required OS is MacOS 15 or later. + +**The app is not notarised yet**, so macOS refuses to open it the first time and +will just quit without saying anything. To launch it : + +Right-click it -> "Open", then open "System Settings", go to "Privacy & Security", +find the DFTFringe security warning, and click "Open Anyway" + +Or, from the terminal : + +``` +xattr -dr com.apple.quarantine /Applications/DFTFringe.app +``` + # How to build DFTFringe on MacOS -:building_construction: Under construction :building_construction: +Dependencies come from [Homebrew](https://brew.sh). Use `opencv@4` and not +`opencv`, which is OpenCV 5 now: + +``` +brew install qt qwt opencv@4 armadillo +``` + +qmake finds them through pkg-config, so nothing is hard coded in the project file. +`opencv@4` is keg-only and Qt6 is split across several kegs, so point +`PKG_CONFIG_PATH` at them: + +``` +export PKG_CONFIG_PATH="$(brew --prefix qwt)/lib/pkgconfig:$(brew --prefix opencv@4)/lib/pkgconfig:$(brew --prefix armadillo)/lib/pkgconfig:$(brew --prefix)/lib/pkgconfig" +$(brew --prefix qt)/bin/qmake DFTFringe.pro CONFIG+=release +make -j$(sysctl -n hw.ncpu) +``` + +That gives you `build/release/DFTFringe.app`, still linked against Homebrew. +`macdeployqt` copies the libraries in and rewrites their install names. The colour +maps have to go in `Contents/Resources`, because `Contents/MacOS` may hold nothing +but code and `codesign` rejects the bundle otherwise: + +``` +$(brew --prefix qt)/bin/macdeployqt build/release/DFTFringe.app +cp -R ColorMaps build/release/DFTFringe.app/Contents/Resources/ +open build/release/DFTFringe.app +``` + +A build targets the machine it runs on, since Homebrew libraries are single +architecture. The CI builds both and merges them with `lipo` into the universal +application that ships. See `.github/workflows/build-macos.yml`. # How to build DFTFringe on Windows diff --git a/colormapviewerdlg.cpp b/colormapviewerdlg.cpp index 6fab068f..326d0f7a 100644 --- a/colormapviewerdlg.cpp +++ b/colormapviewerdlg.cpp @@ -34,6 +34,14 @@ colorMapViewerDlg::colorMapViewerDlg(QWidget *parent) : { QSettings set; gpath = qApp->applicationDirPath() + "/ColorMaps"; +#ifdef Q_OS_MAC + // Inside an application bundle the executable sits in Contents/MacOS, which + // may hold nothing but code: codesign refuses to sign data files there and + // the resulting signature is rejected, so the colour maps ship one level up + // in Contents/Resources instead. + if (!QDir(gpath).exists()) + gpath = qApp->applicationDirPath() + "/../Resources/ColorMaps"; +#endif ui->setupUi(this); ui->path->setText(gpath); ui->listWidget->setViewMode(QListWidget::IconMode);