diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f8d13ab..4f5eb4c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -173,6 +173,10 @@ jobs: runs-on: windows-latest permissions: contents: write + # As in macos-dmg: a secret cannot be read in an `if:`, an + # environment variable can. + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} strategy: fail-fast: false matrix: @@ -211,6 +215,42 @@ jobs: - name: Build the installer if: github.ref_type == 'tag' && matrix.configuration == '' run: make installer + + # SignPath signs from an artifact of this run rather than from a + # key on the runner - the certificate lives in their hardware and + # never comes here - so the installer is uploaded first and the + # signed copy comes back to replace it. + - name: Keep the unsigned installer + id: installer + if: github.ref_type == 'tag' && matrix.configuration == '' + uses: actions/upload-artifact@v4 + with: + name: avanor-windows-installer-unsigned + # [0-9] so this cannot pick up avanor-d.exe by accident + path: avanor-[0-9]*.exe + if-no-files-found: error + + # Skipped when there is no token - a fork, a pull request, or an + # upstream that has not applied to SignPath yet - and the unsigned + # installer goes on the release instead, as it did before. See + # SIGNING.md. + - name: Sign the installer + if: github.ref_type == 'tag' && matrix.configuration == '' + && env.SIGNPATH_API_TOKEN != '' + uses: signpath/github-action-submit-signing-request@v1 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ vars.SIGNPATH_POLICY_SLUG }} + github-artifact-id: ${{ steps.installer.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: signed + - name: Take the signed installer over the unsigned one + if: github.ref_type == 'tag' && matrix.configuration == '' + && env.SIGNPATH_API_TOKEN != '' + run: mv -f signed/avanor-[0-9]*.exe . + - name: Add it to the release if: github.ref_type == 'tag' && matrix.configuration == '' uses: softprops/action-gh-release@v3 @@ -219,11 +259,12 @@ jobs: files: avanor-[0-9]*.exe fail_on_unmatched_files: true + # Compiles and tests every configuration, on whatever macOS is newest, + # so a break there is seen when it lands. The release images are built + # by macos-dmg below, on runners pinned for the reasons given there. macos: name: macos ${{ matrix.configuration || 'release' }} runs-on: macos-latest - permissions: - contents: write strategy: fail-fast: false matrix: @@ -231,7 +272,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install dependencies - run: brew install argparse cereal coreutils create-dmg dylibbundler fmt luajit make notcurses pkgconf sol2 wget zstd + run: brew install argparse cereal coreutils fmt luajit make notcurses pkgconf sol2 wget zstd # gmake, not make: macOS still ships GNU Make 3.81 from 2006, which # reads a "#" inside $(shell ...) as the start of a comment and cuts # the line in half. Homebrew's is the same 4.x every other platform @@ -246,14 +287,121 @@ jobs: CONFIGURATION: ${{ matrix.configuration }} run: ./.github/run-tests.sh - # As on Windows: only on a tag, and only from the release build of - # the default backend, since all four would write the same file. + # The disk images, one per architecture, on a tag only. + # + # A Mach-O built here holds one architecture and the Homebrew libraries + # bundled beside it hold the same one, so an image made on an Apple + # silicon runner does not run on an Intel Mac at all - Rosetta goes from + # x86_64 to arm64, not back. Hence a runner of each kind rather than the + # `macos-latest` the job above uses. + # + # The OS is pinned for the same reason it is named: every Homebrew + # bottle is built for the OS of the machine that installed it, so the + # runner image decides the lowest macOS the release can run on, and + # `macos-latest` quietly moves that floor up with every new release of + # macOS. macos-15-intel is served until August 2027, after which the + # x86_64 half of this matrix goes away with it. + macos-dmg: + name: macos disk image (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + if: github.ref_type == 'tag' + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: arm64 + - runner: macos-15-intel + arch: x86_64 + # A secret cannot be read in an `if:`, only an environment variable + # can, so the one that decides whether there is anything to sign with + # is lifted up here for the step below to test. + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + steps: + - uses: actions/checkout@v7 + - name: Install dependencies + run: brew install argparse cereal create-dmg dylibbundler fmt luajit make pkgconf sol2 wget zstd + - name: Build + run: gmake -j$(sysctl -n hw.ncpu) + - name: Check the binary starts + run: ./avanor --version + + # The runner labels decide which architecture each half of the + # matrix builds, and a label that quietly changes what it points at + # would otherwise put the same slice in both images under two + # different names. Say out loud which one was expected. + - name: Check it is the architecture this job is for + run: | + built=$(lipo -archs ./avanor) + if [ "$built" != "${{ matrix.arch }}" ]; then + echo "::error::${{ matrix.runner }} built $built, expected ${{ matrix.arch }}" + exit 1 + fi + + # Everything the signing needs, put where the Makefile will find it. + # Skipped when the certificate is not there - a fork has no secrets + # - and the build below then produces the unsigned image instead of + # failing. See SIGNING.md for where these five secrets come from. + - name: Set up the signing key + if: env.MACOS_CERTIFICATE != '' + env: + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + NOTARY_KEY: ${{ secrets.NOTARY_KEY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }} + run: | + set -eu + + keychain="$RUNNER_TEMP/signing.keychain-db" + password=$(uuidgen) + + printf '%s' "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + printf '%s' "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary.p8" + + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + security import "$RUNNER_TEMP/cert.p12" -k "$keychain" \ + -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + + # Without this codesign stops to ask permission to use the key, + # of a runner with nobody at it to answer. + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$password" "$keychain" > /dev/null + + # The default keychain, not merely a searched one: notarytool + # reads the default when it is given a profile name. + security list-keychains -d user -s "$keychain" + security default-keychain -d user -s "$keychain" + + xcrun notarytool store-credentials AVANOR_NOTARY \ + --key "$RUNNER_TEMP/notary.p8" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER" \ + --keychain "$keychain" + + rm -f "$RUNNER_TEMP/cert.p12" "$RUNNER_TEMP/notary.p8" + + identity=$(security find-identity -v -p codesigning "$keychain" \ + | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -1) + + if [ -z "$identity" ]; then + echo "::error::no Developer ID Application identity in the certificate" + exit 1 + fi + + echo "CODESIGN_ID=$identity" >> "$GITHUB_ENV" + echo "NOTARY_PROFILE=AVANOR_NOTARY" >> "$GITHUB_ENV" + + # CODESIGN_ID and NOTARY_PROFILE reach make through the environment + # when the step above ran, and are simply unset when it did not. - name: Build the disk image - if: github.ref_type == 'tag' && matrix.configuration == '' run: gmake dmg - name: Add it to the release - if: github.ref_type == 'tag' && matrix.configuration == '' uses: softprops/action-gh-release@v3 with: - files: avanor-[0-9]*.dmg + files: avanor-[0-9]*-${{ matrix.arch }}.dmg fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 14e92e0..287cce9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ datafiles.nsh avanor-*.exe avanor-*.dmg +# The zip the application is notarised from. Made and removed inside +# the dmg target, so it is only ever left behind by a failed run. +avanor-*-app.zip + # Generated by dpkg-buildpackage debian/avanor/ debian/files diff --git a/Main.cpp b/Main.cpp index 33ce71e..a7f132a 100644 --- a/Main.cpp +++ b/Main.cpp @@ -27,6 +27,12 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. #include #include +#ifdef __APPLE__ + #include + #include + #include +#endif + #include #include #include @@ -587,8 +593,67 @@ void ShowLogo() XGame Game; +#ifdef __APPLE__ +// Avanor draws on a terminal, and an application opened from Finder has +// none. What used to bridge that was a shell script beside the game in +// the bundle, with the script as the bundle's executable; the trouble is +// that this left the game a plain executable standing next to the thing +// macOS had approved rather than being it, and a plain executable can +// carry no notarisation ticket of its own - so it was assessed a second +// time, on its own, and that assessment can only be answered by asking +// Apple over the network. Approving Avanor and then opening it again +// with no network would fail. +// +// So the bundle's executable is the game, and the game is what hands +// itself to Terminal. +// +// LaunchServices sets __CFBundleIdentifier to the bundle it is starting, +// which is how this tells being opened from Finder from being run in a +// shell. Terminal sets the variable too - to its own identifier - so the +// copy it goes on to run does not bounce back here, and neither does +// `./avanor --version` in CI, which has no bundle and no terminal. +static void vRelaunchInTerminal() +{ + // Must match CFBundleIdentifier in resources/Info.plist. + static const char* const BundleId = "com.avanor.avanor"; + + const char* const launched = getenv("__CFBundleIdentifier"); + + if (launched == nullptr || strcmp(launched, BundleId) != 0) { + return; + } + + if (isatty(STDOUT_FILENO)) { + return; + } + + char path[PATH_MAX]; + uint32_t size = sizeof(path); + + if (_NSGetExecutablePath(path, &size) != 0) { + return; + } + + execl("/usr/bin/open", "open", "-a", "Terminal", path, + static_cast(nullptr)); + + // Only reached if open could not be started at all. Carrying on would + // draw the game where nobody can see it, so say why instead. + std::cerr << "avanor: cannot open Terminal to play in" << std::endl; + + exit(1); +} +#endif + + int main(int argc, char* argv[]) { +#ifdef __APPLE__ + // Before the arguments are read: opened from Finder there are none + // worth reading, and this does not come back. + vRelaunchInTerminal(); +#endif + argparse::ArgumentParser program("avanor", GAME_VERSION_FULL); program.add_description("Avanor, the Land of Mystery - a roguelike."); diff --git a/Makefile b/Makefile index ed385e8..e3b6d65 100644 --- a/Makefile +++ b/Makefile @@ -323,13 +323,71 @@ installer: $(NAME) mainfiles.nsh datafiles.nsh avanor.nsi # # The bundle keeps the game in Contents/MacOS and its world and manual in # Contents/Resources, which is the layout vEnterDataDir() knows to look -# for. What Finder runs is resources/avanor-launcher rather than the game -# itself - see the comment in that file. +# for. What Finder runs is the game itself, which opens a Terminal to +# draw in when it finds it was started without one - see +# vRelaunchInTerminal() in Main.cpp. # # dylibbundler copies the Homebrew libraries the binary needs into the # bundle and rewrites its load paths to point inside it, so the .dmg runs # on a Mac that has never seen Homebrew. -DMG := avanor-$(VERSION).dmg +# +# The image is named for the architecture it holds, because it only ever +# holds the one: the binary and the Homebrew libraries beside it are all +# single-slice, and an arm64 build does not start on an Intel Mac - +# Rosetta translates x86_64 to arm64 and not the other way about. CI +# builds one image on a runner of each kind and puts both on the release. +# +# Signing is off unless CODESIGN_ID names a Developer ID Application +# identity, and notarisation off unless NOTARY_PROFILE also names a +# notarytool keychain profile. Neither is set in a fork, in a pull +# request, or on anybody's own machine, and without them this builds +# precisely the unsigned image it always has: there is no second path +# kept alive for the unsigned case, only this one with the signing +# collapsed to `:`, the shell command that does nothing with whatever it +# is handed. See SIGNING.md for where the two values come from. +CODESIGN_ID ?= +NOTARY_PROFILE ?= + +# Signed either way, and the difference is what with. With an identity: +# the hardened runtime and the entitlements, which is what notarisation +# requires. Without one: an ad-hoc seal, which Gatekeeper still refuses - +# as it should, nobody has vouched for this - but refuses cleanly, as an +# application from an unidentified developer, which is the refusal the +# README tells people how to get past. +# +# The alternative is not "no signature": the linker leaves an ad-hoc one +# on the executable whatever we do, and since the executable is now the +# bundle's own, a bundle with no seal around it is a bundle whose +# signature says it has resources and has none. codesign calls that "code +# has no resources but signature indicates they must be present", and +# macOS can put it to the user as "Avanor is damaged and can't be +# opened" - alarming, and untrue. +# +# No hardened runtime on the ad-hoc path. It turns on library validation, +# which requires the libraries and the executable to share a team, and an +# ad-hoc signature has no team at all - so the three bundled libraries +# would refuse to load and the game would not start. +sign_lib = $(if $(CODESIGN_ID),codesign --force --timestamp --options runtime \ + --sign "$(CODESIGN_ID)",codesign --force --sign -) +sign_app = $(if $(CODESIGN_ID),codesign --force --timestamp --options runtime \ + --entitlements resources/avanor.entitlements --sign "$(CODESIGN_ID)",\ + codesign --force --sign -) +notarise = $(if $(NOTARY_PROFILE),xcrun notarytool submit \ + --keychain-profile "$(NOTARY_PROFILE)" --wait,:) +staple = $(if $(NOTARY_PROFILE),xcrun stapler staple,:) + +ARCH := $(shell uname -m) +DMG := avanor-$(VERSION)-$(ARCH).dmg + +# The backdrop tells the user what to do, and - when the image is not +# signed - what macOS is about to say when they do it. A signed image +# gets the one without that warning, since it does not apply to it. See +# make-background.sh. +DMG_BACKGROUND := resources/dmg-background$(if $(CODESIGN_ID),,-unsigned).png + +# Notarisation is handed a zip rather than the bundle. ditto, not zip: +# zip(1) drops the extended attributes and symlinks a bundle is made of. +NOTARY_ZIP := avanor-$(VERSION)-$(ARCH)-app.zip DMGROOT := dmgroot APPDIR := $(DMGROOT)/Avanor.app @@ -341,8 +399,6 @@ dmg: $(NAME) resources/Avanor.icns mkdir -p $(APPDIR)/Contents/MacOS $(APPDIR)/Contents/Resources cp -p $(NAME) $(APPDIR)/Contents/MacOS/ - cp -p resources/avanor-launcher $(APPDIR)/Contents/MacOS/ - chmod +x $(APPDIR)/Contents/MacOS/avanor-launcher dylibbundler -cd -b -of \ -x $(APPDIR)/Contents/MacOS/$(NAME) \ @@ -359,20 +415,77 @@ dmg: $(NAME) resources/Avanor.icns "Add :CFBundleShortVersionString string $(VERSION_NUMBER)" \ $(APPDIR)/Contents/Info.plist + # What the bundle claims to need has to be what it was built against. + # Every Homebrew bottle is compiled for the OS of the machine it was + # installed on, so the floor is whatever the build machine ran, and + # LC_BUILD_VERSION is where that is written down. Promising less than + # the truth does not widen the audience, it only swaps LaunchServices + # naming the macOS this needs for dyld failing over a library. + /usr/libexec/PlistBuddy -c \ + "Set :LSMinimumSystemVersion $$(otool -l $(NAME) \ + | awk '/LC_BUILD_VERSION/ { f = 1 } f && /minos/ { print $$2; exit }')" \ + $(APPDIR)/Contents/Info.plist + + # Signing runs from the inside out, and has to: Contents/MacOS is a + # place macOS looks for nested code, so the bundle's own signature + # records those by the hash of *their* signatures - which therefore + # have to exist first. Nothing may be touched afterwards either, which + # is why dylibbundler rewrote the load paths further up and not here. + # + # No --deep. It has been deprecated for signing since macOS 13, and it + # would apply these entitlements to the three libraries as well, which + # is not what they are for. + @for lib in $(APPDIR)/Contents/MacOS/libs/*.dylib; do \ + echo $(sign_lib) "$$lib"; $(sign_lib) "$$lib" || exit 1; \ + done + # The entitlements go on the bundle, not on the game beneath it. + # Signing a bundle signs its executable too, and would throw away a + # signature already put there - entitlements and all - leaving + # something that verifies perfectly and has none of them. Since the + # game is now the bundle's executable rather than a file beside one, + # this signs both at once and is the only place they can be given. + $(sign_app) $(APPDIR) + codesign --verify --deep --strict --verbose=2 $(APPDIR) + + # Notarised twice, and stapled twice, because the two tickets are not + # the same ticket. The one on the disk image is left behind the moment + # the application is dragged out of it; only the one stapled into the + # application travels to /Applications, and that is the copy that has + # to open on a machine that is offline. + $(if $(NOTARY_PROFILE),ditto -c -k --keepParent $(APPDIR) $(NOTARY_ZIP)) + $(notarise) $(NOTARY_ZIP) + $(if $(NOTARY_PROFILE),$(RM) $(NOTARY_ZIP)) + # stapler is the gate rather than notarytool: it is what fails if the + # submission came back anything other than accepted. + $(staple) $(APPDIR) + cp -p COPYING CHANGELOG.md README.md $(DMGROOT)/ + # The window and the backdrop behind it are one layout, drawn by + # resources/make-background.sh from these same coordinates - move an + # icon here and the arrow there points at nothing. The taller window + # is what makes room for what the backdrop has to say underneath. + # + # No icon is nearer the left edge than 114: Finder will not put one + # there, and it shifts the whole window's worth of icons rather than + # clamp the one, which is how a README at 70 drags everything else + # out from under the backdrop. See the note in make-background.sh. create-dmg \ - --volname "Avanor $(VERSION)" \ + --volname "Avanor $(VERSION) $(ARCH)" \ --volicon resources/Avanor.icns \ + --background $(DMG_BACKGROUND) \ --window-pos 400 100 \ - --window-size 480 360 \ + --window-size 480 460 \ --icon-size 64 \ - --icon "Avanor.app" 110 130 \ + --icon "Avanor.app" 130 130 \ --hide-extension "Avanor.app" \ --app-drop-link 350 130 \ - --icon "README.md" 70 260 \ - --icon "CHANGELOG.md" 190 260 \ - --icon "COPYING" 310 260 \ + --icon "README.md" 120 260 \ + --icon "CHANGELOG.md" 240 260 \ + --icon "COPYING" 360 260 \ + --no-internet-enable \ + $(if $(CODESIGN_ID),--codesign "$(CODESIGN_ID)") \ + $(if $(NOTARY_PROFILE),--notarize "$(NOTARY_PROFILE)") \ $(DMG) $(DMGROOT)/ $(RM) -r $(DMGROOT) diff --git a/README.md b/README.md index cbb1ef5..7d83802 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - [Project History](#project-history) - [Current Status](#current-status) - [Features](#features) +- [Installing a release](#installing-a-release) - [Building and Running](#building-and-running) - [Required C++ modules](#required-c-modules) - [Gameplay](#gameplay) @@ -178,6 +179,67 @@ The project is currently in **active modernization** with the following status: --- +## Installing a release + +Every tag builds a package for each platform and puts it on the +[release](https://github.com/jaydg/avanor/releases). Building from source +is the next section; this one is for the packages. + +### macOS + +There are two disk images and they are not interchangeable, because each +holds one architecture: + +| | For | +| --- | --- | +| `avanor--arm64.dmg` | Apple silicon - M1 and later | +| `avanor--x86_64.dmg` | Intel | + +If you are not sure which you have, the Apple menu, About This Mac, says +so. Taking the wrong one gets you `Bad CPU type in executable`, which is +the only thing it can do - Rosetta translates Intel code for Apple +silicon and not the other way about. + +Open the image and drag Avanor into Applications. + +The first time you open it, macOS may refuse: + +> **"Avanor" Not Opened** - Apple could not verify "Avanor" is free of +> malware that may harm your Mac or compromise your privacy. + +That is Gatekeeper saying the application is not signed by a registered +Apple developer, which costs money the project has not spent. Nothing is +wrong with the download. To open it anyway, go to **System Settings**, +**Privacy & Security**, scroll to the bottom, and click **Open Anyway** +beside the message about Avanor. You only have to do this once. + +Control-clicking the application and choosing Open used to be a quicker +way through this. macOS 15 removed it; System Settings is now the only +route. + +(If you are the person publishing these, [SIGNING.md](SIGNING.md) +describes what it would take to make that dialogue stop appearing.) + +### Windows + +Run `avanor-.exe`. SmartScreen will probably say "Windows +protected your PC" and give the publisher as unknown, for the same reason +as on macOS. Click **More info**, then **Run anyway**. + +### Debian, Ubuntu and Fedora + +Install the `.deb` or the `.rpm` from the release: + +```bash +sudo apt install ./avanor_*.deb # Debian, Ubuntu +sudo dnf install ./avanor-*.rpm # Fedora +``` + +Both put the game on the path as `avanor`, so there is nothing to drag +anywhere and nothing to approve. + +--- + ## Building and Running Avanor builds with **GNU Make**. diff --git a/SIGNING.md b/SIGNING.md new file mode 100644 index 0000000..29cd469 --- /dev/null +++ b/SIGNING.md @@ -0,0 +1,192 @@ +# Signing the releases + +This is for whoever publishes Avanor. Everything it describes is already +built and already in CI; none of it does anything until the accounts +below exist and their secrets are in the repository settings. Until then +every release is produced exactly as it is today, unsigned, and the +disk image tells the user what macOS will say about that. + +Nothing here needs to be done in a hurry, and nothing here needs to be +done at all if the answer is that it is not worth ninety-nine dollars a +year. Read the first section and decide. + +## What this buys, and what it does not + +**macOS.** Today, opening Avanor for the first time fails: *"Avanor" Not +Opened*. The only way through is System Settings, Privacy & Security, +scroll to the bottom, Open Anyway - macOS 15 withdrew the old +Control-click shortcut, so there is no quicker route to point people at. +Signing and notarising removes that entirely. The application opens on a +double click, with no dialogue at all. This is a complete fix, and it is +the reason to spend the money. + +**Windows.** Today SmartScreen says "Windows protected your PC" and names +the publisher as unknown. Signing replaces *unknown publisher* with a +real name, which is most of the benefit. It does **not** make the prompt +go away immediately: since a policy change in 2024 a certificate no +longer buys an exemption, only a reputation, and that accrues as people +download the installer. Expect the prompt to persist for a while and then +stop. This part is free, so the only cost is the application effort. + +## What it costs + +| | Cost | Notes | +| --- | --- | --- | +| Apple Developer Program | **$99/year** | The whole of the macOS cost | +| Developer ID certificate | included | Comes with the membership | +| App Store Connect API key | free | What CI notarises with | +| Notarisation | free | No limit worth worrying about | +| SignPath Foundation | **free** | Windows, for open source | + +So: **$99 a year, and nothing else.** Both alternatives to SignPath are +worse for this project - Azure Trusted Signing is about $10 a month and +is limited to organisations in the US and Canada, and a commercial +certificate is $200-600 a year and, since the key-storage rules changed +in 2023, has to live on a hardware token, which is its own problem for a +build that runs on someone else's machine. + +## Apple, part one: the membership + +Enrol at . + +**Enrol as an Individual, not as an Organization.** They cost the same. +An Organization has to supply a D-U-N-S number registered to a legal +entity, which Avanor does not have and would have to invent a company to +get. An Individual needs none. The only visible difference is that the +certificate, and so the name macOS shows, is a personal name rather than +a project name. + +Identity verification takes days rather than minutes, so start it before +you need it. The membership renews annually; see the last section for +what lapsing does. + +## Apple, part two: the certificate + +1. In the Apple Developer account, under Certificates, create a + certificate of type **Developer ID Application**. Not "Mac + Development", not "Developer ID Installer" - only the Application one + signs an application and a disk image. +2. Download it and open it, which puts it in the login keychain. +3. In Keychain Access, find it, and export **the certificate together + with its private key** as a `.p12`. It will ask for a password; make + one up and keep it, it becomes a secret below. +4. Turn it into text, because that is what a GitHub secret holds: + + ```sh + base64 -i Certificates.p12 | pbcopy + ``` + + That is `MACOS_CERTIFICATE`. The password from step 3 is + `MACOS_CERTIFICATE_PASSWORD`. + +## Apple, part three: the notarisation key + +Notarisation is a separate conversation with Apple, and CI does it with +an App Store Connect API key rather than with an Apple ID and password. +The key is not tied to a person, survives two-factor prompts, and can be +revoked on its own. + +1. In App Store Connect, under Users and Access, Integrations, Keys, + generate a **team key**. Developer access is enough to notarise. +2. Download the `.p8`. **It downloads once.** If it is lost, revoke it + and make another. +3. Note the **Key ID** beside it, and the **Issuer ID** shown above the + list - the issuer is one value for the whole team, not per key. +4. As before: + + ```sh + base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy + ``` + + That is `NOTARY_KEY`; the other two are `NOTARY_KEY_ID` and + `NOTARY_ISSUER`. + +## Windows: SignPath Foundation + +SignPath Foundation signs open source projects at no cost. Avanor +qualifies: the repository is public and the licence is GPLv2+. The +certificate is issued to the Foundation and its private key stays in +their hardware, so nothing sensitive is ever in this repository - CI +uploads the built installer and gets a signed one back. + +Apply through . When the project is approved they +provide an organisation id, a project slug and a signing policy slug. +The token is a secret; the other three are not, and go in repository +**variables** rather than secrets. + +## The secrets, in one place + +Repository Settings, Secrets and variables, Actions. + +**Secrets:** + +| Name | What it holds | From | +| --- | --- | --- | +| `MACOS_CERTIFICATE` | the `.p12`, base64 | Apple, part two | +| `MACOS_CERTIFICATE_PASSWORD` | the password you chose for it | Apple, part two | +| `NOTARY_KEY` | the `.p8`, base64 | Apple, part three | +| `NOTARY_KEY_ID` | the key's id | Apple, part three | +| `NOTARY_ISSUER` | the team's issuer id | Apple, part three | +| `SIGNPATH_API_TOKEN` | the SignPath token | SignPath | + +**Variables:** + +| Name | What it holds | +| --- | --- | +| `SIGNPATH_ORGANIZATION_ID` | from SignPath | +| `SIGNPATH_PROJECT_SLUG` | from SignPath | +| `SIGNPATH_POLICY_SLUG` | from SignPath | + +They are independent. The macOS six and the Windows four can be added +months apart, and each platform starts signing as soon as its own are +there. Adding none of them leaves everything working as it does now. + +## Checking that it worked + +Push a tag, then download the disk image from the release the way a +stranger would - through a browser, not with `curl`, so that it carries +the quarantine flag that makes macOS care. + +```sh +spctl -a -vvv -t exec /Applications/Avanor.app # wanted: accepted +xcrun stapler validate /Applications/Avanor.app # the app, not just the image +codesign -dvv /Applications/Avanor.app # wanted: a Developer ID, a TeamIdentifier +``` + +`spctl` saying `accepted` and a `TeamIdentifier` that is not `not set` +are the two that matter. The honest test is the one that needs no +commands: double-click it and see nothing happen except the game +starting. + +Two things worth knowing before you debug something that is not broken: + +- **Do not test this with ad-hoc signing** (`codesign --sign -`). The + hardened runtime turns on library validation, which requires the + bundled libraries and the game to carry the *same* team, and an ad-hoc + signature has no team at all. It fails with *"mapping process and + mapped file (non-platform) have different Team IDs"*, which looks + alarming and means only that you signed with a dash. A real Developer + ID signs all four with one team and the problem does not exist. +- **Check the Apple silicon image in particular.** The two entitlements + in `resources/avanor.entitlements` exist because the hardened runtime + otherwise forbids LuaJIT from running what it compiles. On Intel this + was tested and the game runs either way; arm64 enforces it far more + strictly, and if it were ever going to bite it would bite there. The + symptom to watch for is not a crash but sluggishness, because the + refusal stops the compiler and not the game. + +## Renewal, expiry and loss + +The membership renews every year. **Let it lapse and new releases stop +being signed** - the build does not fail, it quietly produces the +unsigned image again, which is the right behaviour but is easy not to +notice. Releases already published are unaffected: a notarised, stapled +copy keeps working forever, because the ticket travels with it and does +not get re-checked against a live certificate. + +The Developer ID certificate itself is good for five years. Renewing it +means repeating "Apple, part two" and replacing two secrets. + +If the `.p12` or the `.p8` ever leaks, revoke it in the Apple developer +account first and replace the secret afterwards. Revoking does not +invalidate what was already notarised. diff --git a/resources/Info.plist b/resources/Info.plist index f21cf80..c9f3a40 100644 --- a/resources/Info.plist +++ b/resources/Info.plist @@ -6,11 +6,15 @@ English CFBundleDisplayName Avanor - + CFBundleExecutable - avanor-launcher + avanor CFBundleIconFile Avanor.icns CFBundleIdentifier diff --git a/resources/avanor-launcher b/resources/avanor-launcher deleted file mode 100755 index 0aa30ee..0000000 --- a/resources/avanor-launcher +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -# -# What Finder runs when somebody opens Avanor.app. -# -# Avanor draws on a terminal, and an application launched from Finder does -# not have one - so rather than the game itself, the bundle's executable is -# this, which opens Terminal and starts the game there. The game finds its -# world and manual by looking beside itself and then in ../Resources, so it -# does not matter what directory Terminal happens to start in. -# -# The game binary keeps its own name beside this one, so what Terminal -# shows in its title bar is "avanor" rather than a launcher. - -set -eu - -here=$(cd "$(dirname "$0")" && pwd) - -exec /usr/bin/open -a Terminal "$here/avanor" diff --git a/resources/avanor.entitlements b/resources/avanor.entitlements new file mode 100644 index 0000000..fd4b651 --- /dev/null +++ b/resources/avanor.entitlements @@ -0,0 +1,31 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/resources/dmg-background-unsigned.png b/resources/dmg-background-unsigned.png new file mode 100644 index 0000000..48a43c0 Binary files /dev/null and b/resources/dmg-background-unsigned.png differ diff --git a/resources/dmg-background.png b/resources/dmg-background.png new file mode 100644 index 0000000..3531a1d Binary files /dev/null and b/resources/dmg-background.png differ diff --git a/resources/make-background.sh b/resources/make-background.sh new file mode 100755 index 0000000..33c97cf --- /dev/null +++ b/resources/make-background.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# +# Regenerates the backdrop the macOS disk image window draws behind its +# icons - resources/dmg-background.png, and the -unsigned variant beside +# it. Both are committed; this only has to be run when the wording or the +# layout changes, the way resources/make-icon.sh is. +# +# Two of them, because the honest thing to say depends on the build. A +# signed and notarised image opens on a double click and has nothing to +# explain; an unsigned one is refused by Gatekeeper, and the window is +# the last place anybody reads before that happens. The Makefile picks +# whichever matches what it built, so the picture can never contradict +# what the user is about to see. +# +# The colours are the game's own - xCYAN as engine/global.h defines it, +# on the same near-black the icon is drawn on - so the window looks like +# the thing it installs. +# +# Sizes here are the create-dmg window and icon coordinates in the +# Makefile's dmg target. They are two halves of one layout: move an icon +# there and the arrow here points at nothing. +# +# Needs ImageMagick and the DejaVu fonts. Run from the repository root. + +set -eu + +out=resources/dmg-background.png +out_unsigned=resources/dmg-background-unsigned.png + +# The window, and the two rows of icons Finder puts on top of it. +# +# Every x here is at or past 114. Finder will not place an icon nearer +# the left edge than that, and rather than clamp the one icon it shifts +# every icon in the window by the same amount to keep their spacing - so +# a single icon asked for at 70 slides the whole layout 44 to the right +# and leaves the arrow below pointing at nothing. Keep them all clear of +# it and Finder puts each exactly where it was asked to. +width=480 +height=460 +icon_row=130 # --icon "Avanor.app" / --app-drop-link +app_x=130 +applications_x=350 + +ink='#00A6A6' # xCYAN, as engine/global.h defines it +paper='#12161A' +dim='#6F8184' # ink muted into the paper, for the small print + +# fc-match answers with *something* whatever it is asked for, so a +# machine without DejaVu would quietly render these in whatever it does +# have and the committed files would change for no reason anyone could +# see in the diff. Ask, then check we were given what we asked for. +font_for() { + file=$(fc-match -f '%{file}' "$1") + + case "$file" in + *DejaVu*) ;; + *) + echo "make-background.sh: no DejaVu font for '$1'." >&2 + echo " fontconfig offered: $file" >&2 + echo " Install the DejaVu family and run this again -" >&2 + echo " on macOS: brew install --cask font-dejavu" >&2 + exit 1 + ;; + esac + + echo "$file" +} + +sans=$(font_for "DejaVu Sans") +sans_bold=$(font_for "DejaVu Sans:bold") + +# The arrow runs between the two icons of the top row, stopping clear of +# both: 64-pixel icons centred on icon_row reach 32 to either side, and a +# little more is left so it points at the folder rather than into it. +arrow_from=$((app_x + 46)) +arrow_to=$((applications_x - 58)) +head=$((arrow_to + 16)) + +# ImageMagick stamps the hour it ran into the PNG, which would make +# every regeneration a diff with no visible change in it. -strip and +# excluding the date and time chunks leave the file a function of this +# script alone, so `git diff` after a run means the picture really moved. +plain='-strip -define png:exclude-chunk=date,time' + +draw_common() { + magick -size ${width}x${height} xc:"$paper" \ + -font "$sans_bold" -fill "$ink" -pointsize 17 \ + -gravity North -annotate +0+24 'Drag Avanor into Applications' \ + -stroke "$ink" -strokewidth 3 \ + -draw "line $arrow_from,$icon_row $arrow_to,$icon_row" \ + -stroke none -fill "$ink" \ + -draw "polygon $arrow_to,$((icon_row - 9)) $arrow_to,$((icon_row + 9)) $head,$icon_row" \ + -font "$sans" -fill "$dim" -pointsize 10 \ + -gravity South -annotate +0+18 \ + 'Avanor is free software under the GPL, version 2 or later.' \ + $plain "$1" +} + +# The signed image: nothing to warn about, so nothing is said. +draw_common "$out" + +# The unsigned one. What it describes is the only way through on macOS 15 +# and later, which withdrew the Control-click shortcut that used to open +# an unsigned application - System Settings is now the whole of it. +magick "$out" \ + -stroke "$dim" -strokewidth 1 -draw "line 60,330 420,330" \ + -stroke none \ + -font "$sans_bold" -fill "$ink" -pointsize 13 \ + -gravity North -annotate +0+350 'First launch' \ + -font "$sans" -fill "$dim" -pointsize 11 \ + -gravity North \ + -annotate +0+376 'macOS will refuse to open Avanor: it is not signed by a' \ + -annotate +0+392 'registered Apple developer. Open System Settings > Privacy' \ + -annotate +0+408 '& Security, scroll to the bottom, and click "Open Anyway".' \ + $plain "$out_unsigned" + +echo "wrote $out and $out_unsigned"