@@ -98,7 +98,7 @@ FYNE_UI_DIR = gui_interface/fyne-ui
9898FYNE_UI_BIN = mercury-ui.exe
9999MINGW_GO_CC = x86_64-w64-mingw32-gcc
100100
101- .PHONY : all install internal_deps utils clean doxygen doxygen-clean windows windows-zip windows-installer-signed windows-installer-stage check-installer-names fyne-ui fyne-ui-macos fyne-ui-macos-dmg macos-universal fyne-ui-macos-universal fyne-ui-macos-universal-dmg fyne-ui-windows windows-installer test integration-test FORCE
101+ .PHONY : all install internal_deps utils clean doxygen doxygen-clean windows windows-zip windows-installer-signed windows-installer-stage check-installer-names fyne-ui fyne-ui-macos fyne-ui-macos-dmg macos-universal fyne-ui-macos-universal fyne-ui-macos-universal-dmg sign-macos-bin macos-notarize-dmg fyne-ui-windows windows-installer test integration-test FORCE
102102
103103prefix ?= /usr
104104bindir ?= $(prefix ) /bin
@@ -258,7 +258,61 @@ fyne-ui-macos: libmercury_core.a
258258# Wrap the .app in a compressed, drag-to-install .dmg (Applications symlink).
259259# Run on macOS after fyne-ui-macos. Unsigned — Gatekeeper will warn on first
260260# open (right-click → Open), which is expected for an unnotarised build.
261- MACOS_DMG ?= $(MACOS_APP_NAME ) .dmg
261+ # Version-stamped, like the Windows artifacts (mercury-$(MERCURY_VERSION)-w64-*.zip
262+ # and Mercury_$(MERCURY_VERSION)_Setup.exe). A bare Mercury.dmg is impossible to
263+ # tell apart from the previous one in a downloads folder or on a release page.
264+ MACOS_DMG ?= $(MACOS_APP_NAME ) -$(MERCURY_VERSION ) .dmg
265+ MACOS_DMG_UNIVERSAL ?= $(MACOS_APP_NAME ) -$(MERCURY_VERSION ) -universal.dmg
266+ MACOS_CLI_PARK = mercury-cli-universal
267+
268+ # ---- macOS code signing (rcodesign) --------------------------------------
269+ # github.com/indygreg/apple-platform-rs -- a pure-Rust reimplementation that
270+ # signs Mach-O binaries, BUNDLES, .dmg images and .pkg archives, and notarizes
271+ # and staples, with no Mac, no Xcode and no keychain. Used instead of Apple's
272+ # codesign so the signing certificate never has to reach a macOS runner, and
273+ # instead of anchore/quill, which cannot sign bundles or disk images at all
274+ # (anchore/quill#815, #550, both open) -- the two things Gatekeeper judges.
275+ #
276+ # Verified: a Mercury.app signed by rcodesign 0.28.0 on Linux passes Apple's
277+ # own `codesign --verify --deep --strict` on macOS 15.7.7 ("valid on disk",
278+ # "satisfies its Designated Requirement"), with CodeResources written and the
279+ # universal Mach-O sealed.
280+ #
281+ # NOTE this does not remove macOS from the release entirely: hdiutil builds the
282+ # .dmg and is Apple-only. What moves off the Mac is signing and notarization.
283+ #
284+ # MACOS_SIGN_P12 / MACOS_SIGN_P12_PASSWORD Developer ID cert
285+ #
286+ # Opt-in, same shape as win_sign: with no certificate the build still completes
287+ # and says so, keeping developer builds unchanged. 0.28.0 is the version
288+ # pinned by indygreg/apple-code-sign-action.
289+ RCODESIGN ?= rcodesign
290+
291+ # $(call macos_sign,<mach-o | bundle dir | dmg>)
292+ # --code-signature-flags runtime is the hardened runtime, which notarization
293+ # requires; Apple rejects the upload without it.
294+ define macos_sign
295+ @if [ -n "$(MACOS_SIGN_P12 ) " ]; then \
296+ command -v $(RCODESIGN ) >/dev/null 2>&1 || { \
297+ echo "error: MACOS_SIGN_P12 is set but '$(RCODESIGN ) ' is not installed"; \
298+ echo " https://github.com/indygreg/apple-platform-rs/releases"; \
299+ exit 1; }; \
300+ echo "Signing (rcodesign): $(1 ) "; \
301+ $(RCODESIGN ) sign \
302+ --p12-file "$(MACOS_SIGN_P12 ) " \
303+ --p12-password "$(MACOS_SIGN_P12_PASSWORD ) " \
304+ --code-signature-flags runtime \
305+ $(if $(2 ) ,--binary-identifier "$(2 ) ",) \
306+ "$(1 ) " || exit 1; \
307+ else \
308+ echo "WARNING: MACOS_SIGN_P12 unset — $(1 ) is unsigned"; \
309+ fi
310+ endef
311+
312+ # Sign an already-built artifact by hand (binary, .app or .dmg):
313+ # make sign-macos-bin BIN=mercury MACOS_SIGN_P12=cert.p12 MACOS_SIGN_P12_PASSWORD=...
314+ sign-macos-bin :
315+ $(call macos_sign,$(BIN ) )
262316fyne-ui-macos-dmg : fyne-ui-macos
263317 @echo " Building $( MACOS_DMG) ..."
264318 rm -f $(abspath $(MACOS_DMG ) )
@@ -282,6 +336,7 @@ macos-universal:
282336 @for A in x86_64 arm64; do \
283337 echo " == building mercury slice: $$ A ==" ; \
284338 $(MAKE ) clean > /dev/null; \
339+ $(MAKE ) internal_deps CC=" clang -arch $$ A" || exit 1; \
285340 $(MAKE ) $(BINARY ) CC=" clang -arch $$ A" || exit 1; \
286341 mv $(BINARY ) mercury-$$ A || exit 1; \
287342 done
@@ -317,18 +372,88 @@ fyne-ui-macos-universal:
317372 @lipo -archs $(FYNE_UI_DIR ) /$(MACOS_APP_NAME ) .app/Contents/MacOS/* || true
318373
319374# Universal .app wrapped in a drag-to-install .dmg. The finished .dmg lands at
320- # the repo top level (e.g. ./Mercury.dmg) — the distribution artifact to upload.
321- fyne-ui-macos-universal-dmg : fyne-ui-macos-universal
322- @echo " Building universal $( MACOS_DMG) ..."
323- rm -f $(abspath $(MACOS_DMG ) )
375+ # the repo top level (e.g. ./Mercury-1.9.11-universal.dmg) — the artifact to
376+ # upload.
377+ #
378+ # The image also carries the headless CLI, which it did not before: the Windows
379+ # zip has always shipped mercury.exe next to the GUI, but the Mac image held
380+ # only Mercury.app, so a Mac operator wanting a TNC/uucp station had nothing to
381+ # install. The GUI is built -tags mercury_embedded (the modem is linked into
382+ # it), so the CLI is a genuinely separate artifact, not a duplicate of it.
383+ #
384+ # It is staged NEXT TO the .app rather than inside Contents/MacOS: a drag-install
385+ # still copies exactly one thing, and stray executables inside a bundle are the
386+ # kind of thing that complicates signing/notarisation later.
387+ #
388+ # Ordering here is deliberate and cannot be expressed as prerequisites: BOTH
389+ # universal targets run `make clean` between their two arch slices, and clean
390+ # removes `mercury` AND Mercury.app — so whichever ran second would delete what
391+ # the first produced. Build the CLI first, park it under a name clean does not
392+ # match, then package the .app.
393+ fyne-ui-macos-universal-dmg :
394+ rm -f $(MACOS_CLI_PARK )
395+ $(MAKE ) macos-universal
396+ mv $(BINARY ) $(MACOS_CLI_PARK )
397+ $(MAKE ) fyne-ui-macos-universal
398+ @echo " Building universal $( MACOS_DMG_UNIVERSAL) ..."
399+ rm -f $(abspath $(MACOS_DMG_UNIVERSAL ) )
324400 rm -rf $(FYNE_UI_DIR ) /dmg-stage
325401 mkdir -p $(FYNE_UI_DIR ) /dmg-stage
326402 cp -R $(FYNE_UI_DIR ) /$(MACOS_APP_NAME ) .app $(FYNE_UI_DIR ) /dmg-stage/
327403 ln -s /Applications $(FYNE_UI_DIR ) /dmg-stage/Applications
328- hdiutil create -volname " $( MACOS_APP_NAME) " -srcfolder $(FYNE_UI_DIR ) /dmg-stage \
329- -ov -format UDZO " $( abspath $( MACOS_DMG) ) "
404+ mkdir -p " $( FYNE_UI_DIR) /dmg-stage/Command Line"
405+ cp $(abspath $(MACOS_CLI_PARK ) ) " $( FYNE_UI_DIR) /dmg-stage/Command Line/$( BINARY) "
406+ $(call macos_sign,$(FYNE_UI_DIR ) /dmg-stage/Command Line/$(BINARY ) )
407+ cp mercury.ini.example " $( FYNE_UI_DIR) /dmg-stage/Command Line/"
408+ printf ' %s\n' \
409+ ' Mercury $(MERCURY_VERSION) - command-line (headless) modem' \
410+ ' ' \
411+ ' Mercury.app next to this folder is the GUI and needs nothing else.' \
412+ ' This folder is for running Mercury headless: as a TNC for Winlink/BPQ32,' \
413+ ' or under uucp.' \
414+ ' ' \
415+ ' Install:' \
416+ ' sudo cp mercury /usr/local/bin/' \
417+ ' cp mercury.ini.example ~/.mercury.ini # then edit for your radio' \
418+ ' mercury -h # options' \
419+ ' ' \
420+ ' Universal binary (Intel + Apple Silicon). If this build is unsigned, the' \
421+ ' first run needs: xattr -d com.apple.quarantine /usr/local/bin/mercury' \
422+ > " $( FYNE_UI_DIR) /dmg-stage/Command Line/README.txt"
423+ @# Seal the bundle before it goes into the image: rcodesign recurses into
424+ @# nested Mach-Os and writes Contents/_CodeSignature/CodeResources.
425+ @# Signing the image afterwards does NOT sign what is inside it.
426+ $(call macos_sign,$(FYNE_UI_DIR ) /dmg-stage/$(MACOS_APP_NAME ) .app)
427+ hdiutil create -volname " $( MACOS_APP_NAME) $( MERCURY_VERSION) " \
428+ -srcfolder $(FYNE_UI_DIR ) /dmg-stage \
429+ -ov -format UDZO " $( abspath $( MACOS_DMG_UNIVERSAL) ) "
430+ $(call macos_sign,$(abspath $(MACOS_DMG_UNIVERSAL ) ) ,$(MACOS_APP_ID ) )
330431 rm -rf $(FYNE_UI_DIR ) /dmg-stage
331- @echo " -> $( abspath $( MACOS_DMG) ) (universal)"
432+ rm -f $(abspath $(MACOS_CLI_PARK ) )
433+ @echo " -> $( abspath $( MACOS_DMG_UNIVERSAL) ) (universal, GUI + CLI)"
434+
435+ # ---- Notarization (rcodesign; network, no Mac) ---------------------------
436+ # Separate from signing on purpose: signing is local and offline, this uploads
437+ # to Apple, waits for the verdict and staples the ticket into the image so
438+ # Gatekeeper accepts it offline afterwards.
439+ #
440+ # Credentials are an App Store Connect API key, encoded once into a JSON file:
441+ # rcodesign encode-app-store-connect-api-key -o ~/.mercury-notary.json \
442+ # <issuer-id> <key-id> /path/to/AuthKey_<key-id>.p8
443+ #
444+ # then: make macos-notarize-dmg MACOS_NOTARY_KEY=~/.mercury-notary.json
445+ MACOS_NOTARY_KEY ?=
446+ macos-notarize-dmg :
447+ @[ -n " $( MACOS_NOTARY_KEY) " ] || { \
448+ echo " error: set MACOS_NOTARY_KEY to an encoded App Store Connect key" ; \
449+ echo " rcodesign encode-app-store-connect-api-key -o key.json <issuer> <key-id> AuthKey.p8" ; \
450+ exit 1; }
451+ @[ -f " $( MACOS_DMG_UNIVERSAL) " ] || { \
452+ echo " error: $( MACOS_DMG_UNIVERSAL) not built yet" ; exit 1; }
453+ $(RCODESIGN ) notary-submit \
454+ --api-key-file " $( MACOS_NOTARY_KEY) " --staple \
455+ " $( MACOS_DMG_UNIVERSAL) "
456+ @echo " -> $( abspath $( MACOS_DMG_UNIVERSAL) ) (notarized + stapled)"
332457
333458# ---- Authenticode signing (Windows binaries) ----
334459# Two modes:
@@ -522,6 +647,12 @@ clean:
522647 rm -f $(FYNE_UI_DIR ) /engine/mercury_bridge.o $(FYNE_UI_DIR ) /engine/mercury_bridge_w64.o
523648 rm -f mercury-ui $(FYNE_UI_DIR ) /mercury-ui $(FYNE_UI_DIR ) /mercury-fyne-ui
524649 rm -f $(MACOS_DMG ) $(FYNE_UI_DIR ) /$(MACOS_DMG )
650+ rm -f $(MACOS_DMG_UNIVERSAL ) $(FYNE_UI_DIR ) /$(MACOS_DMG_UNIVERSAL )
651+ @# NOT $(MACOS_CLI_PARK): the dmg recipe parks the CLI there precisely so it
652+ @# survives the `clean` that fyne-ui-macos-universal runs between its arch
653+ @# slices. Cleaning it here deletes the binary mid-build. The recipe removes
654+ @# it itself on success, and clears it before starting so a leftover from a
655+ @# failed run can never be staged as if it were fresh.
525656 rm -rf $(FYNE_UI_DIR ) /$(MACOS_APP_NAME ) .app $(FYNE_UI_DIR ) /dmg-stage
526657 $(MAKE ) -C modem clean
527658 $(MAKE ) -C datalink_arq clean
0 commit comments