See more posts

Creating macOs App Bundles for Crystal Projects Without Xcode

I was recently experimenting with game development, and being a rubyist, it's natural to prototype concepts in Ruby. It's surprisingly simple to do with libraries like Gosu — Check out this quick snake game I made with Ruby and Gosu: SleepingInsomniac/ruby-snake.

Snake made with ruby

Sometimes, however you need to squeeze extra performance or make a more distributable binary. That's when I turn to a compiled language like Swift or Crystal.

Crystal is a fairly new language compiled language with a syntax like ruby. Here's a quote from the readme on GitHub:

Why?

We love Ruby's efficiency for writing code.
We love C's efficiency for running code.
We want the best of both worlds.
We want the compiler to understand what we mean without having to specify types everywhere.
We want full OOP.
Oh, and we don't want to write C code to make the code run faster.

To recreate what I've done with Ruby and Gosu, I turned to Crystal and CrSFML. CrSFML is a great project with bindings to the Simple and Fast Multimedia Library

After reading the documentation and creating a simple game, I got to the point where I wanted to share my binary with friends. After reading about Bundle Structures I was able to package my binary into the correct folder structure, but if I didn't want users to have to install homebrew, the SFML lib, etc.

In order to create a proper bundle I'd have to package those libraries in the bundle as well. Unfortunately this also includes modifying the binary to reference the new location of packaged libs.

Luckily there are a few tools that can help us out:

  • otool : llvm-otool - the otool-compatible command line parser for llvm-objdump
  • install_name_tool : - change dynamic shared library install names

But, first - lets set up a simple cr-sfml project:

Installing dependencies

On macOs, install crystal and sfml through homebrew

zsh
$ brew install crystal sfml

Creating a crystal project

Crystal makes it really easy to create a new project:

zsh
$ crystal init app crsfml-game                                                                                                                                                                    
    create  /Users/alex/Software/crsfml-game/.gitignore
    create  /Users/alex/Software/crsfml-game/.editorconfig
    create  /Users/alex/Software/crsfml-game/LICENSE
    create  /Users/alex/Software/crsfml-game/README.md
    create  /Users/alex/Software/crsfml-game/.travis.yml
    create  /Users/alex/Software/crsfml-game/shard.yml
    create  /Users/alex/Software/crsfml-game/src/crsfml-game.cr
    create  /Users/alex/Software/crsfml-game/spec/spec_helper.cr
    create  /Users/alex/Software/crsfml-game/spec/crsfml-game_spec.cr
Initialized empty Git repository in /Users/alex/Software/crsfml-game/.git/
$ cd crsfml-game

Add crsfml to your dependencies in shards.yml

yaml
dependencies:
  crsfml:
    github: oprypin/crsfml
    version: 2.5.1

Save and run shards install

zsh
$ shards install
Resolving dependencies
Fetching https://github.com/oprypin/crsfml.git
Installing crsfml (2.5.1)
Postinstall of crsfml: make
Writing shard.lock

Now, just copy lib/crsfml/examples/resources and lib/crsfml/examples/snakes.cr to the src directory.

Now, modify shards.yml to point to the src/snakes.cr file:

yaml
targets:
  crsfml-game:
    main: src/snakes.cr

If everything went according to plan, shards build should create a new binary in the /bin folder which launches the snakes game.

Packaging the binary

I created a script called bundle.rb and added the basic structure:

ruby
# bundle.rb

require 'fileutils'

APP_NAME = 'CRGame'
BUILD_DIR = "build/#{APP_NAME}.app/Contents"

puts "Creating structure for #{APP_NAME}:"
[
  BUILD_DIR,
  "#{BUILD_DIR}/MacOS",
  "#{BUILD_DIR}/Resources",
  "#{BUILD_DIR}/Frameworks",
].each do |folder|
  puts "Create: #{folder}"
  FileUtils.mkdir_p folder
end

Next, I needed an Info.plist . This tells macOS about our bundle.

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleGetInfoString</key>
  <string>CR Game</string>
  <key>CFBundleExecutable</key>
  <string>crsfml-game</string>
  <key>CFBundleIdentifier</key>
  <string>com.alexclink.cr-game</string>
  <key>CFBundleName</key>
  <string>CRGame</string>
  <key>CFBundleIconFile</key>
  <string>cr-game.icns</string>
  <key>CFBundleShortVersionString</key>
  <string>0.01</string>
  <key>CFBundleInfoDictionaryVersion</key>
  <string>6.0</string>
  <key>CFBundlePackageType</key>
  <string>APPL</string>
  <key>IFMajorVersion</key>
  <integer>0</integer>
  <key>IFMinorVersion</key>
  <integer>1</integer>
</dict>
</plist>

If you're aiming for a more robust deployment process it would be a good idea to render this as an erb template:

ruby
require 'erb'

File.open("build/CRGame.app/Contents/Info.plist", 'w') do |file|
  file.write ERB.new(File.read('Info.plist.erb'), trim_mode: '-')
    .result_with_hash({
      app_name: 'CRGame',
      build_dir: 'crsfml-game'
    })
end

I'm going to skip that for now though:

ruby
# bundle.rb

# ... structure

puts "Copying Info.plist"
FileUtils.cp 'Info.plist', "#{BUILD_DIR}/"

Next, add the build command. Be sure to include link flags for the modified frameworks path: -rpath @executable_path/../Frameworks . It's also important to include -headerpad_max_install_names since we'll be modifying the binary that's built.

ruby
# bundle.rb

build_cmd = %{shards build --release --link-flags="-rpath @executable_path/../Frameworks -headerpad_max_install_names"}

Now copy the binary to our bundle:

ruby
# bundle.rb
FileUtils.cp "bin/#{BINARY}", "#{BUILD_DIR}/MacOS/#{BINARY}"

Patching the binary

otool -L will show us the libs our binary depends on:

zsh
$ otool -L build/CRGame.app/Contents/MacOS/crsfml-game 
build/CRGame.app/Contents/MacOS/crsfml-game:
    @executable_path/../Frameworks/libsfml-graphics.2.5.dylib (compatibility version 2.5.0, current version 2.5.1)
    @executable_path/../Frameworks/libsfml-window.2.5.dylib (compatibility version 2.5.0, current version 2.5.1)
    @executable_path/../Frameworks/libsfml-system.2.5.dylib (compatibility version 2.5.0, current version 2.5.1)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 800.7.0)
    /usr/lib/libpcre.0.dylib (compatibility version 1.0.0, current version 1.1.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.0.0)
    @executable_path/../Frameworks/libevent-2.1.7.dylib (compatibility version 8.0.0, current version 8.0.0)
    /usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)

Let's gather those:

ruby
  file = 'build/CRGame.app/Contents/MacOS/crsfml-game'
  libs = `otool -L #{file}`.lines
    .map { |l| l[/^.+\.dylib/i]&.strip }

We can reject any system libraries:

ruby
  libs = `otool -L #{file}`.lines
    .map { |l| l[/^.+\.dylib/i]&.strip }
    .reject do |l|
      l.nil? ||
      l.empty? ||
      l.start_with?("/System/Library") ||
      l.start_with?("/usr/lib") ||
      l.start_with?("@rpath")
    end

Now, we just iterate through, copy the lib, and use install_name_tool to patch our binary:

ruby
libs.each do |l_path|
  lib_name = File.basename(l_path)
  dest_dir = "#{BUILD_DIR}/Frameworks"
  dest_path = "#{dest_dir}/#{lib_name}"

  next if File.exist?("#{BUILD_DIR}/Frameworks/#{lib_name}")
  FileUtils.cp l_path, dest_path

  `chown $(id -u):$(id -g) #{dest_path}`
  `chmod +w #{dest_path}`

  `install_name_tool -change #{l_path} @executable_path/../Frameworks/#{lib_name} #{file}`
end

However, we're not done yet. This will fail, because our libs don't have the correct path either. We need to make this a recursive function and call it for every lib:

ruby
def gather_libs(file)
  libs = `otool -L #{file}`.lines
    .map { |l| l[/^.+\.dylib/i]&.strip }
    .reject do |l|
      l.nil? ||
      l.empty? ||
      l.start_with?("/System/Library") ||
      l.start_with?("/usr/lib") ||
      l.start_with?("@rpath")
    end

  libs.each do |l_path|
    lib_name = File.basename(l_path)
    dest_dir = "#{BUILD_DIR}/Frameworks"
    dest_path = "#{dest_dir}/#{lib_name}"
    next if File.exist?("#{BUILD_DIR}/Frameworks/#{lib_name}")
    FileUtils.cp l_path, dest_path
    `chown $(id -u):$(id -g) #{dest_path}`
    `chmod +w #{dest_path}`
    `install_name_tool -change #{l_path} @executable_path/../Frameworks/#{lib_name} #{file}`
    gather_libs dest_path
  end
end

That's it! now you should be able to send the bundle to your users.

macOS now has a feature called gatekeeper which requires code to be signed and notarized before it will run on other machines without issue. For more info on that check out this stack overflow question

Without code signing users, will have to acknowledge they trust your code and run xattr -r -d com.apple.quarantine path/to/CRGame.app to remove the quarantine flag.

Disclaimer:

This is not comprehensive and I've only done this purely out of curiosity.

Complete Script

Here's the finished bundle.rb script:

ruby
#!/usr/bin/env ruby

require 'fileutils'

APP_NAME = 'CRGame'
BUILD_DIR = "build/#{APP_NAME}.app/Contents"
BINARY = 'crsfml-game'

def gather_libs(file)
  libs = `otool -L #{file}`.lines
    .map { |l| l[/^.+\.dylib/i]&.strip }
    .reject do |l|
      l.nil? ||
      l.empty? ||
      l.start_with?("/System/Library") ||
      l.start_with?("/usr/lib") ||
      l.start_with?("@rpath")
    end

  libs.each do |l_path|
    lib_name = File.basename(l_path)
    dest_dir = "#{BUILD_DIR}/Frameworks"
    dest_path = "#{dest_dir}/#{lib_name}"

    puts "#{lib_name}"

    if File.exist?("#{BUILD_DIR}/Frameworks/#{lib_name}")
      puts " - Skipping #{lib_name}: Already present"
      next
    end

    unless File.exist? dest_path
      begin
        puts " - Copy #{l_path} to #{dest_path}"
        FileUtils.cp l_path, dest_path
      rescue Errno::EACCES => e
        puts " - ERROR: #{l_path} cannot be copied: #{e}"
        exit 1
      end
    end

    `chown $(id -u):$(id -g) #{dest_path} && chmod +w #{dest_path}`

    unless $?.success?
      puts "Could not change ownership and add write permissions to #{dest_path}"
      exit 1
    end

    patch_cmd = "install_name_tool -change #{l_path} @executable_path/../Frameworks/#{lib_name} #{file}"
    puts " - Patching executable link: \n   - #{patch_cmd}"
    `#{patch_cmd}`
    unless $?.success?
      exit 1
    end

    # Recursive copy
    gather_libs dest_path
  end
end

puts "Creating structure for #{APP_NAME}:"
[
  BUILD_DIR,
  "#{BUILD_DIR}/MacOS",
  "#{BUILD_DIR}/Resources",
  "#{BUILD_DIR}/Frameworks",
].each do |folder|
  puts "Create: #{folder}"
  FileUtils.mkdir_p folder
end

puts "Copying Info.plist"
FileUtils.cp 'Info.plist', "#{BUILD_DIR}/"

build_cmd = %{shards build --release --link-flags="-rpath @executable_path/../Frameworks -L #{`brew --prefix sfml`.chomp}/lib/ -mmacosx-version-min=10.14 -headerpad_max_install_names"}
puts "Building: `#{build_cmd}`"
puts `#{build_cmd}`

FileUtils.cp "bin/#{BINARY}", "#{BUILD_DIR}/MacOS/#{BINARY}"

puts "Gathering libs..."
gather_libs "#{BUILD_DIR}/MacOS/#{BINARY}"

puts "Done!"

Thanks for reading! See more posts Leave feedback