asset-bundle-0.1.0.1: A build-time Cabal library that bundles executables with assets

Distribution.Simple.AssetBundle

Synopsis

# Motivation

Currently building an executable with Cabal or Stack will create a static binary in their respective bin directory but if it or any of its dependencies reference a local resource such as an image, config file, fonts etc. it is very tedious and error-prone to deploy. This package eases that pain a bit.

This package aims to solve the problem of bundling a Haskell executable with its assets *and* those in its dependencies by providing a couple of functions you can call from your project's Setup.hs so that doing {cabal, stack} build also creates a zip of the executable and all of the assets it needs along with a launch script (a batch file on Windows and a shell script otherwise) that runs the executable adjusting various environment variables so that it can find them at runtime. Even if you're not deploying an executable directly to your users this package still makes it considerably simpler to dry-run on a test machine or in a Docker container.

This package emphatically does not replace a reproducible build system such as Nix because it does not solve the problem of packaging up runtime library dependencies or really anything outside your Cabal file's data-files stanza.

Why zip format instead of an OSX Bundle, a Windows Portable App or a Linux AppImage? Firstly, creating a zip is much simpler to and works well across platforms, secondly, as outlined above, this package does not create a standalone installation and finally zip is ubiqitous and probably comes with your system so you don't have to install anything extra to use this library.

# Goal

Just to be clear on what to expect, if your project provides the executables my-awesome-app and my-awesomer-app using this library will result in two zip files, my-awesome-app_bundled.zip and my-awesomer-app_bundled.zip somewhere deep within your dist or .stack-work directory depending on whether you're using Cabal or Stack. These archives will contain all your assets with the executable and a launch script called run.bat on Windows and run.sh otherwise. See the 'Launch Scripts' section below for more details on what it contains.

# Quick Start

First this library needs to be added as a build-time dependency in your Cabal file with a top level custom-setup stanza:

custom-setup
setup-depends:
Cabal >=1.24.1 && <3
, asset-bundle
, base >=4.4


And at minimum a Setup.hs that uses this library to bundle all the executables in the package:

import Distribution.PackageDescription(PackageDescription)
import Distribution.Simple(defaultMainWithHooks, simpleUserHooks, Args, postCopy)
import qualified Distribution.Simple.AssetBundle as Bundle (postCopy, depsWithData)
import Distribution.Simple.LocalBuildInfo(LocalBuildInfo)
import Distribution.Simple.Setup(CopyFlags)

main = defaultMainWithHooks (simpleUserHooks { postCopy = myPostCopy })

myPostCopy :: Args -> CopyFlags -> PackageDescription -> LocalBuildInfo -> IO ()
myPostCopy args copyFlags pd localBuildInfo = do
(postCopy simpleUserHooks) args copyFlags pd localBuildInfo
deps <- Bundle.depsWithData localBuildInfo
Bundle.postCopy Nothing deps args copyFlags pd localBuildInfo


I'll try to explain it since it's quite a bit messier than the standard:

import Distribution.Simple
main = defaultMain


The Cabal API exposes a record of functions which it calls hooks that are called at the appropriate time in the build cycle which you can override with custom implementations. If, for example, you wanted custom behavior after your lib/executable has finished building you'd override the postBuild hook. If it helps XMonad configuration works a lot like this.

The main function above overrides the postCopy hook to use functions provided by this library (they are explained in further down):

main = defaultMainWithHooks (simpleUserHooks { postCopy = myPostCopy })


The custom hook first lets the default implementation work in:

  (postCopy simpleUserHooks) args copyFlags pd localBuildInfo


And then gathers up all of your project's dependencies using this library's depsWithData function and passes them to the provided postCopy:

  deps <- Bundle.depsWithData localBuildInfo
Bundle.postCopy Nothing deps args copyFlags pd localBuildInfo


# The API

postCopy :: Maybe (Executable -> String) -> [FilePath] -> [InstalledPackageInfo] -> Args -> CopyFlags -> PackageDescription -> LocalBuildInfo -> IO () Source #

A custom postCopy hook gathers executables in the project and zips them up all the assets in this project and it's dependencies.

The zip is named <executable-name>_bundled.zip and it's copied to the Cabal-local bin directory.

It takes a list of InstalledPackageInfo , which can be generated using depsWithData, containing this project's dependencies so it can gather up the resources, a list of extra files (no directories!) [FilePath] you may want to include that are just copied to the root of zip archive, and Maybe (Executable -> String) which allows you to add arbitrary content to the launch script before an executable is run and; more on that below in the 'Adding Custom Code', in the 'Launch Scripts'section below. Most of the time you'll probably want to pass in Nothing.

Generate a list of your project's dependencies by passing in LocalBuildInfo which is available to most of the Cabal API hooks.

# Launch Scripts

## Why?

Launch scripts are needed because when Cabal builds your project it generates a module behind the scenes which hard-codes a number of environment variables that point to needed libraries and other data so that your app can find them at runtime. The module, typically called Paths_<package-name> contains something like:

module Paths_AwesomeApp (
...
) where
...
...


The important bit here is the ...(getEnv "AwesomeApp_datadir")... which tells your project that it should look for assets in the location specified by the "AwesomeApp_datadir" environment variable at runtime and if it doesn't exist fallback on some hardcoded path. All of your project's library dependencies contain a module like this and use a similar looking environment variable (<library-name>_datadir) for their runtime lookup. The purpose of the launch scripts is to point these environemnt variables to the assets local to the zip file before launching the app.

## Structure

On Windows the launch script run.bat looks something like:

@echo off
setlocal
set dir=%~dp0
set PATH=%PATH%;%dir%
set _Tmp=%1
:loop
if not '%2==' set _Tmp=%_Tmp% %2
shift
if not '%2==' goto loop
awesomeApp %_Tmp%
set _Tmp=


The %~dp0 sets dir to the directory in which the script is located and then the PATH is updated.

The rest of the gobbledygook from set _Tmp .. all the way to the second if not '%2==' is necessary because the Windows DOS prompt only sees the first 9 command line arguments. The workaround is a loop which accumulates each command line argument into _Tmp and then splices that into the executable invocation.

The next couple of lines set the Cabal hard-coded environment variables to point to data directories in the archive containing the assets.

The REM from you, dear reader bit is explained in the next section.

On OSX, Linux and the BSD the launch script run.sh is quite a bit simpler:

#!/bin/sh
HERE="$(dirname "$(readlink -f "${0}")")" export PATH="${HERE}":"${PATH}" EXEC=awesomeApp export AwesomeApp_datadir="${HERE}"/AwesomeApp-0.1.0.0
export AwesomeDependency_datadir="${HERE}"/AwesomeDependency-0.1.0.0 # from you, dear reader exec "${EXEC}" "\$@"


No surprises here, except the # from you, dear reader line which is explained next. We set HERE to the directory containing the launcher, update PATH, set the environment variables as we did above and call the executable.

Before the executable is invoked in the launcher scripts you have the opportunity to insert any arbitrary code you desire. It is passed in via the first argument to postCopy and is spliced in at the line marked from you, dear reader in the previous section.