-– This post is a follow-up to my previous post on compiling a binary for distribution: How to compile a portable binary (at least across macs) with `juliac.jl`. —
More cool news!! You can write software in Julia, and then compile it into a standalone application, and then distribute and/or sell that program! That’s a thing now!
I’ve put the content from last post (compiling for distribution) into a script, build_app.jl
, and added logic to turn it into an app. It compiles a julia program into a binary and then wraps it up with all its necessary dependencies into a proper macOS .app bundle. I put the script in a package called ApplicationBuilder.jl
.
https://github.com/NHDaly/ApplicationBuilder.jl
And check it out: I used it to build a simple game, written entirely in Julia! You can see the code and download it here (even if you don’t have julia installed):
https://github.com/NHDaly/PaddleBattleJL/releases/latest
So far, build_app.jl
only supports macOS, but we will extend it to build apps for Windows and Linux as well! @ranjan has been helping with that – thanks @ranjan!
In the rest of this post, I’ll talk in more detail first about how build_app.jl
works and then second about what I had to do to make Paddle Battle into an app.
Most importantly, there are couple changes you have to make in your program to support it running as an app (skip to the code changes section).
Turning code into an app #
You could divide the build-and-release process into three steps: 1. compiling a binary, 2. bundling an app, and 3. releasing and distributing it.
1. compiling a static binary #
juliac.jl
, found in PackageCompiler.jl
, is a tool for producing a statically-compiled, standalone binary.
It runs your code through julia
, outputting a dynamically-linked shared library, and then (optionally) compiles a c program into a binary that invokes that library. For our purposes, it turns .jl
files into an executable binary.
This tool is super cool, and the majority of what I’ve done here is due to the awesome progress @lucatrv, @sdanisch, @jameson, @viralbshah, @TsurHerman and others have made there.
2. packaging an app bundle #
A static binary can give you a run-time performance boost by precomputing the compilation, but it won’t run on other people’s machines unless it is distributed together with all the libraries and resources it depends on, including the julia runtime.
That’s what an Application is for: it bundles together an executable binary and all its dependencies. build_app.jl
creates Applications.
At least on macOS (which is all I’ve tackled so far), a .app
bundle is just a folder that contains a specific structure, including a specific place for the executable and another place for supporting libraries.
build_app.jl
simply creates that directory structure, and then puts the right things in the right places. It uses juliac.jl
to compile your code, and puts the resultant binary (and all the supporting julia runtime libraries) into Contents/MacOS
. It copies any libraries and resources you supply into the right places. These behaviors are controlled with flags.
Consider the example program from the repo, hello.jl. A “hello world” example app has to be different from a generic julia program: it cannot simply println("hello world")
, since anyone who double clicks the app wouldn’t see the output! Instead, the simplest thing I could think of for it to do is open a window in your browser to supply its greeting:
open(filename, "w") do io
write(io, "hello, world\n")
end
run(`open file://$filename`)
When you bundle it into an app, like so:
$ julia build_app.jl examples/hello.jl "HelloWorld"
you can double click the resultant HelloWorld.app
, and it says “hello” from the browser. If you look inside the app, you’ll see the binary and the julia runtime libraries:
HelloWorld.app
↳Contents
↳MacOS # executables go here
hello # the compiled c program that invokes hello.dylib
hello.dylib # our julia code ended up compiled in here
... libjulia.0.6.2.dylib ... # the supporting runtime libraries. lots of these...
↳Resources
HelloWorld.icns # if you don't specify a custom icon, this is copied from the Julia app.
(The Apple documentation says that supporting libraries and frameworks are supposed to go in Contents/Frameworks
, but I thought it made sense to keep the julia runtime libs next to the executable, since organizationally, they are very tightly linked to it. It’s also nice to keep them separate from any user-supplied, app-specific libraries.)
This example is simple because it doesn’t have any extra dependencies. If your app has other binary dependencies (like SDL
for Paddle Battle, discussed below), those have to be copied in as well. The -L
flag is for that. If you have other resources like images or sounds, the -R
flag will copy those into Contents/Resources
.
And that’s basically it for bundling!
As far as I know, this is new – has anyone been doing this already? If so, how are you doing it? Is it similar to what I did here?
3. Releasing your code. #
At this point, HelloWorld.app
is ready to be shared! However, before it will be accepted into formal distribution channels (app stores, etc), there are a few more needed steps.
Apple has a list of the things it requires here: Distributing software on macOS - Apple Developer
At least on macOS and Windows, one required step is code-signing. build_app.jl
supports signing your app with the --certificate
flag. And to support sandboxing on Mac, pass an entitlements file to --entitlements
.
Putting all this together, here is the build command I used for Paddle Battle (see PaddleBattle/build.sh):
julia ~/src/build-jl-app-bundle/build_app.jl --verbose \
-R assets -L "libs/*" --icns "icns.icns" \
--bundle_identifier "com.nhdalyMadeThis.Paddle-Battle" \
--certificate "Developer ID Application: nhdalyMadeThis, LLC" \
--entitlements "./entitlements.entitlements" \
--app_version=1.0 "main.jl" "PaddleBattle"
Pulling in external libraries #
The last detail to cover is the libraries passed to the -L
flag. If your code depends on any external binary dependencies, you need to copy them to your binary so they’re available on other computers. If any of them are unix-style libraries (.dylibs
on macOS), and if they in turn depend on any other libraries, you have to make sure that all their dependency paths are changed to be relative paths rather than absolute paths, and that they don’t reference any dependencies outside your app.
This means, you have to find all your app’s dependencies, copy them, and modify them to point to relative paths. Here is an explanation on that:
macos - Why is install_name_tool and otool necessary for Mach-O libraries in Mac Os X? - Stack Overflow
We’d like to add a utility function for this as well, but it seems difficult to generalize.
Note that it can be hard to tell if you’ve gotten all the external dependencies, since the app will still run on your computer just fine. You can test it by sending your app to a computer that’s never had julia installed, or by inspecting all the app’s dependencies. I found this StackOverflow post to be particularly helpful: StackOverflow–Failure digitally signing a Mac app outside Xcode, especially the export DYLD_PRINT_LIBRARIES=1
trick.
Paddle Battle #
As an end-to-end test case, I made a simple Pong clone called Paddle Battle. The game is written entirely in Julia, using the SDL.jl
graphics library, which was nicely put together by @jonathanBieler.
https://github.com/NHDaly/PaddleBattleJL
You can download the game from the releases tab, or from my game company’s website (I had to make one to satisfy Apple’s Developer requirements):
http://nhdalyMadeThis.website
The game is pretty simple because I want it to act as an example, with all its code available.
Code changes for building as an App #
The most pertinent parts of the PaddleBattle code are the changes required to compile it as an App.
julia_main(ARGS::Vector{String})::Cint #
julia_main
is the entry point for any executable compiled by juliac.jl
. It is the first part of your julia code that will be called.
One thing this means is that functions executed at global scope won’t be run when your program is started. That is, unless they’re used to initialize global-scoped variables. Instead, global-scoped function calls are executed during compilation. So you have to be conscious of that.
All your code, then, should be invoked from julia_main. Here it is for PaddleBattle:
https://github.com/NHDaly/PaddleBattleJL/blob/aa61980/main.jl#L508
change_dir_if_bundle() #
The next big thing is that when your code is executed from within the application, its current working directory (cwd
) will change depending on how it was invoked. On a mac, if double-clicked through Finder, the process’s cwd
will be /
.
Therefore, if your code interacts with the filesystem at all (loads resources), it needs to be able to reliably detect when its running as a compiled app, and then navigate to a familiar location. The way I handled this for Paddle Battle is to copy all necessary resources into Contents/Resources/
, mirroring the structure of the repo root directory. Then, if the code is executing in the app, it changes directory into Resources
, and the rest of the program can proceed identically, unaware of whether it’s an app or a normal julia program.
https://github.com/NHDaly/PaddleBattleJL/blob/aa61980/main.jl#L484-L500
I think most apps will want to do almost exactly that, so I’ve added this as a utility function in ApplicationBuilder.jl
. Your code can simply call this function from julia_main()
, and it should just work:
Base.@ccallable function julia_main(ARGS::Vector{String})::Cint
ApplicationBuilder.change_dir_if_bundle()
# ...
end
(Detecting the location of the compiled code is a bit tricky, and we’re still working out the best way to do that. For now, it uses the Base.julia_cmd()
trick above.)
Package dependencies #
The last change is one that needs to take effect during your code’s compilation, not at runtime. (So, remember, that means it has to be globally-scoped code.)
If your code or any of the Packages it uses make ccall
s, the relative path of the shared library is baked in at compile time. This means that while your code is being compiled, it needs to use the correct, relative path locations for its ccall
s.
In the case of Paddle Battle, the only package I used was SDL.jl
, which makes calls into three shared libraries. Before compiling any functions that make ccall
s into those libraries, I need to change the location where it will look for those libraries!
SDL.jl
uses BinDeps.jl
to manage its dependencies, so it stores the libraries’ paths in the variables libSDL2
, libSDL2_ttf
, etc. The code needs to detect when its being compiled for an app, and in that case override the locations for those libraries, such that the ccall
s will be compiled with a relative path.
To do that, I’ve set up build_app.jl
to set an environment variable that indicates the code is being compile for an apple bundle. PaddleBattle checks for that environment variable, then overrides the locations of those libraries before they’re used. It does this in a global-scoped if-block right after using SDL
:
using SDL
# Override SDL libs locations if this script is being compiled for mac .app builds
if get(ENV, "COMPILING_APPLE_BUNDLE", "false") == "true"
# (note that you can still change these values b/c no functions have
# actually been called yet, and so the original constants haven't been
# "compiled in".)
eval(SDL2, :(libSDL2 = "libSDL2.dylib")) # `eval` executes this line inside the SDL2 module.
eval(SDL2, :(libSDL2_ttf = "libSDL2_ttf.dylib"))
eval(SDL2, :(libSDL2_mixer = "libSDL2_mixer.dylib"))
end
(Note that those lines have to come after using SDL
, so that your definitions override the default, and not the other way around!)
The old values for those globals were absolute paths (e.g. /Users/nhdaly/.julia/v0.6/Homebrew/deps/usr/lib/libSDL2.dylib
). I set the new definitions to be simple relative paths – just the name of the lib. That’s because the -L
flag will copy the binary into the .app
bundle’s Libraries
folder, and build_app.jl
adds a library search path to the compiled binary to search in that folder. Note also that the -L
step happens before compilation, because the libraries have to be available in order for the ccall
s to compile. (The Libraries
directory is then made available via LD_LIBRARY_PATH
while compiling, and set via install_name_tool
on the final binary.)
You’ll have to do this for any filesystem dependencies a package has, not just binary dependencies. For example, if your app uses Blink.jl
, you’ll likely also want to include main.js
, main.html
, and other resources. Here is what @ranjan used for an app that uses Blink:
using Blink
if get(ENV, "COMPILING_APPLE_BUNDLE", "false") == "true"
eval(Blink.AtomShell, :(_electron = "Julia.app/Contents/MacOS/Julia"))
eval(Blink.AtomShell, :(mainjs = "main.js"))
eval(Blink, :(buzz = "main.html"))
eval(Blink, :(resources = Dict("spinner.css" => "res/spinner.css",
"blink.js" => "res/blink.js",
"blink.css" => "res/blink.css",
"reset.css" => "res/reset.css")))
eval(HttpParser, :(lib = "libhttp_parser.dylib"))
end
And that’s it! There are just a few code changes you have to make, and a little bit of build configuration, and then in theory you should be able to turn any julia program into a compiled, portable, distributable app!
The next step for Paddle Battle is putting it on the Mac App Store. I’ve gotten a Developer ID from Apple, and I’m going to start the App Store submission process this week. I think it will be really cool for Julia to have presence on the app store! I will post another followup post when that’s completed.