Skip to content

asdf-macos-app — build a signed, notarisable macOS .app bundle from an SBCL image #2615

Open

Description

@lispnik

asdf-macos-app is an ASDF extension that turns a system into a macOS .app
bundle. You add a class and a build operation to the defsystem and asdf:make
does the rest — Info.plist, icon conversion, dylib relocation, code signing,
verification, and notarisation:

(defsystem "editor"
  :defsystem-depends-on ("asdf-macos-app")
  :class :macos-app-system
  :build-operation "macos-app-op"
  :entry-point "editor:main"
  :bundle-identifier "com.example.editor"
  :bundle-name "Editor"
  :bundle-icon "res/icon.png"
  :code-signing-identity "Developer ID Application: Jane Doe (ABCDE12345)"
  :components ((:file "main")))

Two problems shape the design, and both are the sort you only find by hitting
them.

The image dump kills the process. save-lisp-and-die never returns, so
nothing that must happen after the executable exists — signing, dylib
relocation, verification — can run in the same image. The operation therefore
builds the skeleton, spawns a child SBCL to dump into it, and finishes in the
parent. The child gets an explicit source registry naming every system in the
resolved dependency closure, because inheriting CL_SOURCE_REGISTRY is not
enough when the parent found systems through asdf:*central-registry*.

An SBCL image dumped with :executable t cannot be codesigned at all.
save-lisp-and-die appends the core to the runtime's Mach-O — past the end of
__LINKEDIT and past the code signature — and codesign refuses the trailing
data with main executable failed strict validation, for a Developer ID as much
as for ad hoc. So a bundle here holds the SBCL runtime as its executable, the
core beside it in Contents/Resources as a sealed resource, and a relative
symlink between them: the runtime finds a core named sbcl.core beside itself
with no arguments, which is what LaunchServices gives a double-clicked app, while
codesign seals the link as an ordinary resource rather than refusing it as
unsigned nested code. No C launcher, so no C toolchain in the build.

The result signs with the hardened runtime, passes codesign --verify --deep --strict, launches through LaunchServices, detects a tampered core, and
notarises. There is a notarize entry point that submits and staples.

SBCL only, and the bundles it builds are macOS only — though the library
itself loads and its layout/plist half runs anywhere, which is what the Linux CI
leg covers. macos-app:*allow-non-macos-build* assembles a bundle off macOS for
smoke tests; the result is marked Contents/INCOMPLETE-BUILD, and that marker is
acted on rather than advisory — such a bundle refuses to replace a complete one
and cannot be notarised.

:description, :author and :license are set on both systems, the README
documents the options and the known limits, and CI runs the suite on Linux and
macOS
on every push: 203 checks. The macOS leg is the one that matters — it
runs plutil, real codesign, and codesign --verify against an actual SBCL
image, which is the riskiest part of the design.

It is in use: https://github.com/lispnik/utc-status-app ships a signed and
notarised disk image built with it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions