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.
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-objdumpinstall_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
$ brew install crystal sfml
Creating a crystal project
Crystal makes it really easy to create a new project:
$ 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
dependencies:
crsfml:
github: oprypin/crsfml
version: 2.5.1
Save and run shards install
$ 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:
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:
# 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 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:
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:
# 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.
# bundle.rb
build_cmd = %{shards build --release --link-flags="-rpath @executable_path/../Frameworks -headerpad_max_install_names"}
Now copy the binary to our bundle:
# bundle.rb
FileUtils.cp "bin/#{BINARY}", "#{BUILD_DIR}/MacOS/#{BINARY}"
Patching the binary
otool -L
will show us the libs our binary depends on:
$ 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:
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:
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:
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:
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:
#!/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!"