Documentation
¶
Overview ¶
Package darwinbundle builds, signs and packages macOS application bundles from Go, without Xcode and, for most of it, without a Mac.
The pieces are the ones a release build of an app needs, each usable on its own:
- Bundle assembles Name.app, with app extensions nested in it, around binaries you have already compiled: the directory layout, Info.plist, the executable's permissions, resources, and an icon compiled from an .appiconset.
- Sign signs a bundle or a binary with a certificate from a PKCS#12 file, a Keychain identity (package keychain), or ad hoc.
- Verify and Inspect ask Apple's codesign what it makes of the result.
- MakeUniversal merges per-architecture binaries into one universal binary.
- Zip archives a bundle, keeping the symbolic links and executable bits that a bundle needs to survive the trip.
Because none of this shells out to Apple's tools to produce its output, a distribution build can run on a Linux CI runner. The exception is checking a signature: only codesign can say whether macOS will accept one, so Verify and Inspect need a Mac, and say so plainly elsewhere.
Packages ¶
This package is the one most programs need. The rest are what it is built from, exported because they are useful alone:
- appiconset compiles an .appiconset directory into an Assets.car.
- assetcatalog writes the CoreUI structures inside an Assets.car.
- bom reads and writes Apple's Bill of Materials container.
- keychain signs with an identity held in the macOS Keychain, so the private key never has to be exported (macOS only).
Requirements ¶
Go 1.26 or newer, and nothing else: no cgo, no Xcode. Building, signing with a certificate file or ad hoc, universal binaries and archives all work on any platform Go does. Only Verify, Inspect and package keychain need a Mac.
One limitation is worth knowing before you start. Signing an application that contains an app extension needs a fix to quill that is not yet in a release (anchore/quill#883). Without it, Sign returns ErrNestedBundlesUnsupported, which explains what to add to your go.mod. Everything else is unaffected.
An example ¶
See the Example in this package for assembling, signing and archiving an application with an extension.
Example ¶
A release build, from compiled binaries to a signed archive: universal binaries, an application with an extension and an icon, signed from the inside out, checked, and zipped.
The examples in this file are compiled by go test, so they cannot drift from the API, but they are not run: they need real Mach-O binaries and a signing identity.
package main
import (
"context"
"log"
"path/filepath"
"github.com/TotallyGamerJet/darwinbundle"
"github.com/TotallyGamerJet/darwinbundle/keychain"
)
func main() {
const out = "dist"
// Merge the per-architecture builds. Do this before signing: Sign signs each
// slice of a universal binary, but cannot merge signed slices.
if err := darwinbundle.MakeUniversal("build/notes", "build/notes-arm64", "build/notes-amd64"); err != nil {
log.Fatal(err)
}
if err := darwinbundle.MakeUniversal("build/sync", "build/sync-arm64", "build/sync-amd64"); err != nil {
log.Fatal(err)
}
app := &darwinbundle.Bundle{
Kind: darwinbundle.KindApp,
Name: "Notes",
Identifier: "com.example.Notes",
Version: "1.4.0",
BuildNumber: "212",
MinimumSystem: "13.0",
Executable: "build/notes",
IconSet: "assets/AppIcon.appiconset",
PlugIns: []*darwinbundle.Bundle{{
Kind: darwinbundle.KindExtension,
Name: "NotesSync",
DisplayName: "Notes", // what the user sees, not the file name
// An extension's identifier must be prefixed by its application's.
Identifier: "com.example.Notes.Sync",
Version: "1.4.0",
BuildNumber: "212",
Executable: "build/sync",
// Everything specific to this kind of extension is data, not part
// of the library.
Info: map[string]any{
"NSExtension": map[string]any{
"NSExtensionPointIdentifier": "com.apple.fileprovider-nonui",
"NSExtensionPrincipalClass": "SyncExtension",
},
},
}},
}
appPath, err := app.Build(out)
if err != nil {
log.Fatal(err)
}
// The key stays in the Keychain; darwinbundle never sees it.
signer, err := keychain.Find("Developer ID Application")
if err != nil {
log.Fatal(err)
}
defer func() {
if err := signer.Close(); err != nil {
log.Print(err)
}
}()
// From the inside out: the application's signature seals the extension's.
ext := app.PlugIns[0]
for _, target := range []struct{ path, entitlements string }{
{ext.Path(app.PlugInsDir(out)), "entitlements/sync.plist"},
{appPath, "entitlements/notes.plist"},
} {
err := darwinbundle.Sign(target.path, darwinbundle.SignConfig{
Signer: signer,
Entitlements: target.entitlements,
TimestampServer: "http://timestamp.apple.com/ts01",
})
if err != nil {
log.Fatal(err)
}
}
// Signing is portable; checking is not. This needs a Mac.
if res, err := darwinbundle.Verify(context.Background(), appPath, darwinbundle.VerifyOptions{Deep: true}); err != nil {
log.Fatalf("%v\n%s", err, res.Output)
}
if err := darwinbundle.Zip(filepath.Join(out, "Notes.zip"), appPath); err != nil {
log.Fatal(err)
}
}
Output:
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
"so sign here and verify on a Mac")
ErrCodesignUnavailable is returned, wrapped, by Verify and Inspect when Apple's codesign is not on the PATH. It ships with macOS and exists nowhere else.
That asymmetry is deliberate rather than an omission. Signing is portable: Sign runs anywhere. Checking a signature is not, because only codesign can say whether macOS will accept it. The alternative to this error is `exec: "codesign": executable file not found in $PATH` from a few frames down, which reads as a broken CI image rather than as a step that was never going to work on this machine.
var ErrNestedBundlesUnsupported = errors.New(
"this quill cannot seal an application that contains another bundle (anchore/quill#883); see ErrNestedBundlesUnsupported")
ErrNestedBundlesUnsupported is returned, wrapped, when the build of quill in use declines to sign an application because it contains another bundle, an app extension for instance. Test for it with errors.Is.
It is a limitation of quill rather than of this package, and it is temporary: anchore/quill#883 teaches quill to seal nested bundles by reference, the way codesign does. Until a release contains it, a program that signs such an application has to build against a quill that does, by adding to its go.mod
replace github.com/anchore/quill => github.com/TotallyGamerJet/quill <version>
with the version this module's own go.mod names. A replace in a dependency's go.mod is not applied to the programs that import it, which is why this cannot be done for you. The README has the current details.
Signing an application that contains no other bundle is not affected.
Functions ¶
func Architectures ¶
Architectures reports the architectures present in a Mach-O file, thin or universal. It is how a build verifies it produced what it meant to.
func MakeUniversal ¶
MakeUniversal merges thin Mach-O binaries into one universal binary at out.
The inputs must be distinct architectures; lipo rejects duplicates, which is the error worth getting rather than a fat file with two identical slices. The output is made executable, which lipo's own is not.
func Sign ¶
func Sign(path string, cfg SignConfig) error
Sign signs the Mach-O binary, universal binary or bundle at path with quill.
Signing is pure Go and runs anywhere. Checking the result is not: only Apple's codesign can say whether macOS will accept a signature, so Verify needs a Mac.
Sign a bundle's contents from the inside out. Signing an application seals the signatures of the extensions nested in it by reference, so an extension signed after the application invalidates the application's seal.
Example (AdHoc) ¶
package main
import (
"log"
"github.com/TotallyGamerJet/darwinbundle"
)
func main() {
// Ad hoc must be chosen: a SignConfig that names no identity is an error.
// An ad hoc signature belongs to no team, so it is not enough for code that
// needs a team-prefixed App Group.
if err := darwinbundle.Sign("dist/Hello.app", darwinbundle.SignConfig{AdHoc: true}); err != nil {
log.Fatal(err)
}
}
Output:
Example (NestedBundles) ¶
package main
import (
"errors"
"log"
"github.com/TotallyGamerJet/darwinbundle"
)
func main() {
err := darwinbundle.Sign("dist/Notes.app", darwinbundle.SignConfig{AdHoc: true})
if errors.Is(err, darwinbundle.ErrNestedBundlesUnsupported) {
// Upstream quill cannot yet seal an application that contains an
// extension. The README says what to add to go.mod.
log.Fatal("this build cannot sign applications containing extensions: ", err)
}
}
Output:
Example (P12) ¶
package main
import (
"log"
"github.com/TotallyGamerJet/darwinbundle"
)
func main() {
// For a CI runner that has the identity as a secret rather than in a
// Keychain.
err := darwinbundle.Sign("dist/Hello.app", darwinbundle.SignConfig{
P12Path: "secrets/developer-id.p12",
P12Password: "from the environment, not from source",
})
if err != nil {
log.Fatal(err)
}
}
Output:
func Zip ¶
Zip writes the directory tree at src into a zip archive at out.
Entries are prefixed with src's own directory name, which is what `ditto -c -k --keepParent` does and what makes unpacking recreate Name.app rather than spilling Contents/ into the current directory.
Empty directories are recorded, permissions and modification times are preserved, and anything that is not a regular file, a directory or a symlink is an error rather than a silent omission: an archive missing a file is a worse outcome than one that was never written.
Types ¶
type Bundle ¶
type Bundle struct {
// Kind says whether this is an application or an extension. Required.
Kind Kind
// Name is the bundle's file name without its suffix: "Notes" for Notes.app.
// It is also the executable's name unless ExecutableName says otherwise, so
// it must be safe as a single path element.
Name string
// DisplayName is the name shown to the user, written as CFBundleDisplayName
// and CFBundleName. It defaults to Name.
//
// For an extension this matters more than it looks. The system labels what
// an extension provides with the extension's own display name and never
// consults the containing application's, so an extension left at its file
// name is what the user sees in their own sidebar.
DisplayName string
// Identifier is the CFBundleIdentifier, in reverse-DNS form. Apple permits
// only letters, digits, hyphens and periods. An extension's identifier must
// be prefixed by its containing application's followed by a period. Required.
Identifier string
// Version is the user-visible version, CFBundleShortVersionString.
// BuildNumber is CFBundleVersion. MinimumSystem is the oldest macOS that can
// run the bundle, LSMinimumSystemVersion.
//
// Each is left out of Info.plist when empty, rather than written as an
// empty string the system would take literally.
Version, BuildNumber, MinimumSystem string
// Executable is the path of the compiled binary to copy into the bundle.
// It is copied rather than linked, so that signing the bundle never
// modifies the build output. Required.
Executable string
// ExecutableName is the name the binary is given inside Contents/MacOS, and
// CFBundleExecutable. It defaults to Name.
ExecutableName string
// IconSet is the path of an .appiconset directory to compile into
// Contents/Resources/Assets.car, and CFBundleIconName is set to match.
//
// Empty means no icon. That is a real choice, not an omission: the bundle
// gets no Assets.car and no CFBundleIconName, rather than an empty catalog
// and a plist entry pointing at nothing.
IconSet string
// Resources maps a path inside Contents/Resources to the file or directory
// to copy there. Directories are copied recursively; anything other than a
// regular file or directory, a symbolic link included, is an error.
Resources map[string]string
// Info holds Info.plist entries beyond the ones the fields above generate.
// Where a key is in both, Info wins: it is the escape hatch for anything a
// field does not cover (NSExtension, LSUIElement, CFBundleDocumentTypes).
//
// Values may be strings, booleans, integers, floats, []byte, time.Time,
// string-keyed maps, and slices of those. The one key that cannot be
// changed is CFBundleExecutable, which must name the file this build
// creates; setting it to anything else is an error.
Info map[string]any
// PlugIns are the extensions nested inside an application. Each is built
// into Contents/PlugIns, and each must have Kind KindExtension.
PlugIns []*Bundle
}
Bundle describes a bundle to assemble: an application, optionally with app extensions nested inside it.
Name.app/
└── Contents/
├── Info.plist
├── PkgInfo (applications only)
├── MacOS/Name
├── Resources/
│ └── Assets.car (when IconSet is set)
└── PlugIns/
└── Ext.appex/
└── Contents/ ... (the same shape, recursively)
The zero value is not usable; Kind, Name, Identifier and Executable are required. Build checks everything before it writes anything.
Example ¶
package main
import (
"fmt"
"log"
"github.com/TotallyGamerJet/darwinbundle"
)
func main() {
b := &darwinbundle.Bundle{
Kind: darwinbundle.KindApp,
Name: "Hello",
Identifier: "com.example.Hello",
Version: "1.0",
Executable: "build/hello", // already compiled
Info: map[string]any{
// Anything the fields do not cover goes in Info, and wins over what
// they generate.
"LSUIElement": true, // a menu bar agent with no Dock icon
"NSHumanReadableCopyright": "© 2026 Example",
},
}
path, err := b.Build("dist")
if err != nil {
log.Fatal(err)
}
fmt.Println("built", path)
}
Output:
func (*Bundle) Build ¶
Build assembles the bundle, and the extensions nested in it, into dir, and returns the path of the bundle it made.
Code signing seals a bundle's entire contents, so a file left over from an earlier build with different settings would be sealed into the signature and shipped. Build therefore replaces any bundle already at the destination rather than adding to it. It assembles in a temporary directory beside the destination and moves the result into place only once everything has succeeded, so a build that fails leaves the previous bundle as it was.
func (*Bundle) ExecutablePath ¶
ExecutablePath returns where the bundle's binary ends up when it is built into dir.
func (*Bundle) Path ¶
Path returns where the bundle lives when built into dir: dir/Name.app, or dir/Name.appex.
func (*Bundle) PlugInsDir ¶
PlugInsDir returns the directory that holds the bundle's nested extensions when it is built into dir. Pass it to an extension's Path or ExecutablePath to find that extension inside its application.
type SignConfig ¶
type SignConfig struct {
// Signer signs with a certificate and key held somewhere that never gives
// up the key, such as the Keychain. It is the preferred way: exporting an
// identity to a PKCS#12 file puts a private key on disk, usually somewhere
// that later gets archived, synced or committed by accident.
Signer Signer
// P12Path and P12Password sign with a certificate and key from a PKCS#12
// file. This is for environments with no Keychain, a CI runner holding the
// identity as a secret above all.
P12Path string
P12Password string
// AdHoc signs without a certificate. That is enough to check that a bundle's
// structure and resource seal are right, and it is what Apple Silicon wants
// of any code it runs at all, but macOS will not load an extension that
// needs an App Group from code signed this way.
AdHoc bool
// Identifier is the identifier recorded in the code directory, what
// codesign's -i sets. Empty means the bundle's own identifier, which is what
// codesign does.
Identifier string
// Entitlements is the path of an entitlements plist applied to what is being
// signed. Each binary or bundle takes its own: the host application and its
// extension generally have different ones.
Entitlements string
// TimestampServer is the URL of an RFC 3161 timestamp service. A trusted
// timestamp is what lets a signature outlive the certificate's expiry, and
// notarisation requires one. It needs a certificate, so it cannot be
// combined with AdHoc.
TimestampServer string
}
SignConfig says how to sign. Exactly one of Signer, P12Path and AdHoc must be set.
Nothing is defaulted. A configuration that names no identity is an error rather than an ad hoc signature, because the two look identical until the result fails to load: an ad hoc signature belongs to no team, so the sandbox refuses it a team-prefixed App Group and an extension signed that way cannot read its own configuration. Choosing ad hoc has to be something you wrote.
type Signature ¶
type Signature struct {
// Signed is false for code that carries no signature at all. The other
// fields are then zero.
Signed bool
// Identifier is the identifier in the code directory.
Identifier string
// TeamID is the Team ID the signing certificate carries, or empty if there is
// none, which is the case for ad hoc signatures and for Apple's own platform
// binaries.
TeamID string
// AdHoc reports a signature made without a certificate.
AdHoc bool
// HardenedRuntime reports whether the runtime flag is set in the code
// directory. Notarisation requires it.
HardenedRuntime bool
}
Signature describes how a binary or bundle is signed.
func Inspect ¶
Inspect reads how the code at path is signed.
The distinction it exists to draw is the one that decides whether a build works, and it is invisible until it fails. A File Provider extension reaches its shared configuration through an App Group whose identifier is prefixed with the Team ID, and the sandbox grants that group only to code signed by that team. Ad hoc code belongs to no team, so the grant is refused and every read of the group container returns EPERM. Nothing about that is loud: the extension launches, resolves the container path, and is denied the file.
An unsigned file is not an error; it is a Signature with Signed false.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"log"
"github.com/TotallyGamerJet/darwinbundle"
)
func main() {
sig, err := darwinbundle.Inspect(context.Background(), "dist/Hello.app")
if errors.Is(err, darwinbundle.ErrCodesignUnavailable) {
log.Fatal("checking a signature needs a Mac")
}
if err != nil {
log.Fatal(err)
}
switch {
case !sig.Signed:
fmt.Println("not signed")
case sig.AdHoc:
fmt.Println("ad hoc: belongs to no team")
default:
fmt.Println("signed by team", sig.TeamID)
}
}
Output:
type Signer ¶
type Signer interface {
crypto.Signer
// Certificates returns the signing certificate followed by its issuers: the
// leaf first, then intermediates, root last if present. This is the order
// the Security framework and most tooling produce.
Certificates() []*x509.Certificate
}
Signer is a code-signing identity: a key that signs digests, and the certificate chain that vouches for it.
Package keychain provides one backed by the macOS Keychain, whose key never leaves the Security framework. Anything else that can sign — a hardware token, a cloud KMS — can be adapted by implementing these two methods.
type VerifyOptions ¶
type VerifyOptions struct {
// Deep checks the contents of a bundle as well as the bundle: nested code
// such as an app extension, and every resource the seal covers. It also
// applies codesign's --strict checks. Use it for an application; there is
// nothing to look inside for a lone binary.
Deep bool
}
VerifyOptions tunes Verify.
type VerifyResult ¶
type VerifyResult struct {
Path string
// Valid is whether codesign accepted the signature.
Valid bool
// Output is codesign's own account, verbose, with surrounding whitespace
// trimmed. It is the thing to show someone when Valid is false.
Output string
// Entitlements is the XML plist of entitlements the signature carries, if
// it carries any.
Entitlements string
}
VerifyResult reports what codesign said about one path.
func Verify ¶
func Verify(ctx context.Context, path string, opts VerifyOptions) (VerifyResult, error)
Verify asks codesign whether the signature on path is valid.
The error is non-nil exactly when the signature is not valid, or codesign could not be run; in the first case the VerifyResult still holds what codesign printed.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package appiconset compiles a macOS application icon, as Xcode lays it out on disk, into the Assets.car that goes in a bundle's Contents/Resources.
|
Package appiconset compiles a macOS application icon, as Xcode lays it out on disk, into the Assets.car that goes in a bundle's Contents/Resources. |
|
Package assetcatalog writes the CoreUI structures that make a BOM container a compiled asset catalog: an Assets.car.
|
Package assetcatalog writes the CoreUI structures that make a BOM container a compiled asset catalog: an Assets.car. |
|
Package bom reads and writes Apple's Bill of Materials container.
|
Package bom reads and writes Apple's Bill of Materials container. |
|
internal
|
|
|
cocoa
Package cocoa is the small part of the Objective-C runtime that package keychain needs: building NSString, NSData and NSDictionary values, reading bytes back out, and running work inside an autorelease pool.
|
Package cocoa is the small part of the Objective-C runtime that package keychain needs: building NSString, NSData and NSDictionary values, reading bytes back out, and running work inside an autorelease pool. |
|
keychaintest
Package keychaintest builds a throwaway Keychain holding generated code-signing identities, so that tests can exercise the real Security framework without touching, or prompting for, the developer's own keychain.
|
Package keychaintest builds a throwaway Keychain holding generated code-signing identities, so that tests can exercise the real Security framework without touching, or prompting for, the developer's own keychain. |
|
plist
Package plist encodes Go values as XML property lists, which is all a bundle build needs of the format: Info.plist is written, never read.
|
Package plist encodes Go values as XML property lists, which is all a bundle build needs of the format: Info.plist is written, never read. |
|
Package keychain signs with a code-signing identity held in the macOS Keychain, so the private key never has to leave it.
|
Package keychain signs with a code-signing identity held in the macOS Keychain, so the private key never has to leave it. |