Skip to content

Commit 8321efc

Browse files
rafael2kclaude
andcommitted
macos: version-stamp the .dmg, ship the CLI in it, and sign with rcodesign
Three gaps in the macOS packaging, all visible next to the Windows side. The image was always named Mercury.dmg, with nothing to distinguish 1.9.10 from 1.9.11 in a downloads folder or on a release page -- while Windows has shipped mercury-$(MERCURY_VERSION)-w64-<hash>.zip and Mercury_$(MERCURY_VERSION)_Setup.exe all along. MERCURY_VERSION was already parsed from common/mercury_version.h for exactly that purpose; the .dmg simply never used it. Now Mercury-1.9.11-universal.dmg. The image also held only Mercury.app. There is a universal CLI target, but nothing copied its output in, so a Mac operator wanting a headless station -- a TNC for Winlink/BPQ32, or uucp -- had nothing to install. The GUI is built -tags mercury_embedded with the modem linked in, so the CLI is a genuinely separate artifact. It goes in a "Command Line" folder beside the .app with mercury.ini.example and a README, so a drag-install still copies exactly one thing and nothing extra lands inside the bundle to complicate signing. And there was no signing. Added opt-in signing via rcodesign (github.com/indygreg/apple-platform-rs): the CLI, then the .app bundle, then the image -- in that order, because signing a disk image does NOT sign what is inside it. Notarization is a separate target (macos-notarize-dmg) since it needs the network and Apple's verdict. Missing credentials warn instead of failing, so developer builds are unchanged. rcodesign rather than Apple's codesign or anchore/quill. Quill was tried first and rejected: it signs Mach-O binaries only, and cannot sign bundles or disk images (anchore/quill#815, #550 both open, no CodeResources support in its tree) -- which is everything Gatekeeper actually judges. rcodesign covers Mach-O, bundles, .dmg and .pkg, plus notarize and staple, in pure Rust with no Mac, no Xcode and no keychain, so the signing certificate never has to reach a macOS runner. Note this does not remove macOS from the release entirely: hdiutil builds the image and is Apple-only. What moves off the Mac is signing and notarization. Two pre-existing bugs surfaced while testing this: - macos-universal never invoked internal_deps, so after its own `make clean` the link failed on a missing modem/freedv/libfreedvdata.a. That target could not work on a clean tree; nothing had called it from the dmg path before, so it went unnoticed. - the two universal targets both run `make clean`, which removes `mercury` AND Mercury.app, so they cannot be ordered as prerequisites -- whichever ran second deleted the other's output. The CLI is now built first and parked under a name clean does not match (and deliberately NOT added to clean, which would defeat it), cleared at the start of the recipe so a leftover from a failed run can never be staged as if fresh. Tested on macOS 15.7.7 (x86_64): - image mounts and contains Mercury.app, Applications, and Command Line/{mercury,mercury.ini.example,README.txt} - both binaries report `lipo -archs` = x86_64 arm64 - the CLI runs: "Mercury Version 1.9.11 (git 6dec3b0)" - a Mercury.app signed by rcodesign 0.28.0 ON LINUX passes Apple's own `codesign --verify --deep --strict`: "valid on disk", "satisfies its Designated Requirement", with CodeResources written and the universal Mach-O sealed - the .dmg signed on Linux likewise verifies and still mounts Signed with a self-signed test certificate, so spctl still rejects it -- that is Gatekeeper judging the credential, not the signature. A real Developer ID plus notarization is the remaining gate, and is a credentials matter rather than a code one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6dec3b0 commit 8321efc

1 file changed

Lines changed: 140 additions & 9 deletions

File tree

Makefile

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ FYNE_UI_DIR = gui_interface/fyne-ui
9898
FYNE_UI_BIN = mercury-ui.exe
9999
MINGW_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

103103
prefix ?= /usr
104104
bindir ?= $(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))
262316
fyne-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

Comments
 (0)