Fanny being Zipped How to use the AppBuilder script to create a minimal Fantom installation that runs your application.

Let's say you've created a cool Fantom application, like the Gundam Game, and you want to share it with your mates.

But installing it on your mates' computers can be quite a hassle:

  • First they needs Fantom installed
  • Then they needs a copy of your .pod library.
  • Then they needs a copy of all the .pods your .pod depends on (whose versions may conflict with those they're already using)
  • They may also need .jar files, like swt.jar, specific to their computers' platforms
  • They may also need specific files from %FAN_HOME%/etc/ * folders or elsewhere

And all you want is for them to play your game!

Wouldn't it great if instead, you could just hand them a .zip file that contained everything they needed? A .zip file with a simple script file that launched the application?

Well, you can!

Enter AppBuilder.fan!

This simple Fantom script let's you do just that!

It creates a minimal Fantom installation with only those pods and files required to run your application. Meaning your application can be run in isolation to any other Fantom installation. No missing pods, no version conflicts, no missing files, just your app - running happily!

All your application needs to run, is java available on the command line.

AppBuilder is a simple class that copies application files into a build directory and zips them up.

A minimal build script looks like:

AppBuilder("<pod-name>").build()

Where <pod-name> is the name of your application's pod.

To use, both the Main program that runs the script, and the AppBuilder class have to be in the same file.

Therefore a sample build script for flux would look like:

select all
class Main {
    static Void main() {
        AppBuilder("flux") { it.jarFiles = ["swt.jar"] }.build()
    }
}

const class AppBuilder {
    ...
    ...
    ...
}

The file may be called anything you like, but I tend to use AppBuilder.fan. Then to run it, I type:

select all
C:\> fan AppBuilder.fan

Packaging Fantom Core 1.0.67
============================

Copying Java runtime...
  - copied lib\java\sys.jar

Copying application pods...
  - copied lib\fan\flux.pod
  - copied lib\fan\sys.pod
  - copied lib\fan\concurrent.pod
  - copied lib\fan\gfx.pod
  - copied lib\fan\fwt.pod
  - copied lib\fan\compiler.pod
  - copied etc\flux
  - copied etc\sys

Copying jar files...
  - copied lib\java\ext\linux-x86\swt.jar
  - copied lib\java\ext\macosx-x86_64\swt.jar
  - copied lib\java\ext\win32-x86\swt.jar
  - copied lib\java\ext\win32-x86_64\swt.jar

Creating script files...
  - copied fanlaunch
  - copied flux
  - copied flux.bat

Calling user function...

Compressing to flux-1.0.67.zip ...
   - C:\Temp\build\flux-1.0.67.zip

Done.

The Default Build

The default build process does the following...

  1. Creates a build directory
  2. Copies Fantom's sys.jar
  3. Copies your application's pod and any dependent pods
  4. Copies any specified jar files for desired platforms
  5. Creates a simple script file to launch the app
  6. Compresses the build directory to a .zip file

All files are copied from your existing Fantom installation. Some people customise their Fantom environment by specifying the FAN_ENV environment variable (usually to util::PathEnv), and AppBuilder takes this into account. But by setting AppBuilder.useEnv to false then all files are resolved relative to the %FAN_HOME% directory.

When a pod is copied, then all the pods it depends on copied over too. And all the pods they depend on, and so on... Also, the contents of any related <FANTOM_HOME>/etc/* directory is copied over too - both for the named pod, and any dependent pod.

When a .jar file is copied, it is looked for in both the <FANTOM-HOME>/lib/java/ folder and in any nested platform folder. This lets you create a minimal installation that targets a specific platform such as Windows 64 bit.

Basic script files are created that call java and launch Fantom, passing in any specified script arguments. The script arguments default to the name of the pod.

All the above can be customised by changing the script's field values in the AppBuilders ctor. Fields that may be customised are:

  • File buildDir
  • Bool useEnv
  • File fantomHomeDir
  • Str[] excludePods
  • Str[] jarFiles
  • Str[] platforms
  • Str scriptArgs

For more details on the specific fields, read the API comments in the script source.

Customising the Build

The AppBuilder.build() method takes an optional func parameter that lets you perform extra build steps just before the application directory is zipped up. The function takes an AppBuilder parameter which makes available several useful methods:

  • findFile()
  • copyFile()
  • copyPod()
  • copyJarFiles()
  • createScriptFiles()
  • compressToZip()

All the above methods are used by the main build script itself. Read the API comments in the script source for further information.

AppBuilder.fan

The AppBuilder script is available as a BitBucket Snippet and is also given below:

select all
class Main {
    static Void main() {
        AppBuilder("myPodName") {
            // it.jarFiles = ["swt.jar"]
            // it.platforms = ["win32-x86_64"]
            // it.scriptArgs = "${it.podName} -cheatMode on"
        }.build |bob| {
            // bob.copyFile(findFile(`etc/myapp/hiscores.txt`), `etc/myapp/`)
        }
    }
}



// ---- Do not edit below this line ---------------------------------------------------------------

** Fantom App Builder v0.0.4
** =========================
** Creates a standalone Fantom application installation with the minimum number of files.
**
** v0.0.4 - Added 'findPodFile()' to make work with Fantom Pod Manager.
** v0.0.2 - Initial release.
**
const class AppBuilder {

    ** The name of the main application pod.
    const Str podName

    ** The directory where the application is assembled.
    ** Defaults to 'build/'
    const File buildDir

    ** If 'true' (the default), then files (pods and libraries) are located using the current 'Env'.
    ** If 'false', files are taken to be relative to the 'fantomHomeDir'.
    **
    ** See 'findFile()' method.
    const Bool useEnv := true

    ** The Home directory of the Fantom installation.
    ** If 'useEnv' is 'false' then all pods and libraries are taken from this location.
    ** Defaults to `sys::Env.homeDir`.
    **
    **   fantomHomeDir = File.os("C:\\Apps\\fantom-1.0.68\\")
    const File fantomHomeDir := Env.cur.homeDir

    ** Names of pods that should not be included in the distribution.
    **
    **   excludePods = ["compiler"]
    const Str[] excludePods := Str[,]

    ** A list of java libraries (jar file names) that are to be copied over.
    ** Jar files are copied from 'lib/java/ext/' and 'lib/java/ext/XXXX/' where 'XXXX' is a matching platform name.
    ** See 'platforms' field for details.
    **
    **   jarFiles = ["swt.jar"]
    const Str[] jarFiles := Str[,]

    ** A list of platforms (regex globs) that '.jar' files will be copied over for. Use to target specific platforms:
    **
    **   platforms = ["win32-x86*"]  // is the same as
    **   platforms = ["win32-x86", "win32-x86_64"]
    **
    ** Defaults to 'Str["*"]' which targets *all* platforms.
    const Str[] platforms := Str["*"]

    ** Arguments to pass to the fantom launch scripts.
    **
    ** Defaults to 'podName' to launch the application.
    const Str scriptArgs

    private const File _distDir

    ** Creates an instance of 'AppBuilder' for the given pod.
    new make(Str podName, |This|? in := null) {
        this.podName = podName
        this.buildDir = File(typeof->sourceFile.toStr.toUri).normalize.parent + `build/`
        in?.call(this)

        if (!buildDir.isDir)
            throw ArgErr("buildDir is NOT a directory - ${buildDir.normalize.osPath}")
        if (!fantomHomeDir.isDir)
            throw ArgErr("fantomHomeDir is NOT a directory - ${fantomHomeDir.normalize.osPath}")

        this.buildDir = this.buildDir.normalize
        this._distDir = this.buildDir + `${podName}-${Pod.find(podName).version}/`

        if (scriptArgs == null)
            scriptArgs = podName
    }

    ** Builds the packaged installation.
    ** 'extra' is called before the .zip file is created to allow you to perform any extra tasks;
    ** such as copying over surplus files.
    Void build(|AppBuilder|? extra := null) {
        pod  := Pod.find(podName)
        name := (pod.meta["proj.name"] ?: podName) + " ${pod.version}"
        log
        log("Packaging ${name}")
        log("".padl(name.size+10, '='))

        // clean
        if (_distDir.exists) {
            log("\nDeleting `${_distDir.osPath}`...")
            _distDir.delete
        }
        _distDir.create

        // copy java runtime
        log("\nCopying Java runtime...")
        copyFile(findFile(`lib/java/sys.jar`), `lib/java/`)

        // copy pods
        log("\nCopying application pods...")
        copyPod(podName)

        // copy jars
        if (jarFiles.size > 0) {
            log("\nCopying jar files...")
            copyJarFiles(jarFiles, platforms)
        }

        log("\nCreating script files...")
        createScriptFiles(podName, scriptArgs)

        if (extra != null) {
            log("\nCalling user function...")
            extra.call(this)
        }

        zipName := `${podName}-${Pod.find(podName).version}.zip`
        log("\nCompressing to ${zipName} ...")
        zipFile := compressToZip(_distDir, zipName)
        log("   - ${zipFile.osPath}")
        _distDir.delete

        log("\nDone.")
    }

    ** Copies over jar file from the existing Fantom environment.
    ** The parameters have the same meaning as `jarFiles` and `platforms`.
    Void copyJarFiles(Str[] jarFiles, Str[] platformGlobs) {
        jarFiles.each |jarFileName| {
            copyFile(findFile(`lib/java/ext/${jarFileName}`, false), `lib/java/ext/`)

            extDir := findFile(`lib/java/ext/`, false)
            if (extDir != null) {
                platformGlobs.each |platform| {
                    extDir.listDirs(Regex.glob(platform)).each |libDir| {
                        copyFile(findFile(`lib/java/ext/${libDir.name}/${jarFileName}`, false), `lib/java/ext/${libDir.name}/`)
                    }
                }
            }
        }
    }

    ** Copies over the named pod, along with all (transitive) dependencies and any associated 'etc/' property directory.
    Void copyPod(Str podName, Bool copyEtcFiles := true, Bool copyDependencies := true) {
        podNames := copyDependencies ? _findPodDependencies(Str[,], podName).unique : [podName]

        podNames.unique.each |pod| {
            // log versions of non-core pods
            ver := Pod.find(pod).version == Pod.find("sys").version ? "" : " (v${Pod.find(pod).version})"
            _copyFile(findPodFile(pod, true), `lib/fan/${pod}.pod`, false, ver)
        }

        if (copyEtcFiles)
            podNames.unique.each |pod| {
                copyFile(findFile(`etc/${pod}/`, false), `etc/${pod}/`)
            }
    }

    ** Copies the given file to the destination URL - which is relative to the output folder.
    ** Returns the destination file, or null if 'srcFile' is 'null' or does not exist.
    **
    **   copyFile(`fan://acme/res/config.props`.get, `etc/config.props`)
    **
    ** If 'srcFile' is a dir, then the entire directory tree is copied over.
    **
    ** If 'destUrl' is a dir, then the file is copied into it.
    File? copyFile(File? srcFile, Uri destUri, Bool overwrite := false) {
        _copyFile(srcFile, destUri, overwrite, Str.defVal)
    }

    ** Compresses the given file to a .zip file at the destination URL- which is relative to the output folder.
    ** Returns the compressed .zip file.
    **
    ** 'toCompress' may be a directory.
    File compressToZip(File toCompress, Uri destUri) {
        if (destUri.isDir)
            throw ArgErr("Destination can not be a directory - ${destUri}")
        if (!destUri.isPathOnly)
            throw ArgErr(_msg_urlMustBePathOnly("destUri", destUri, `my-app.zip`))
        if (destUri.isPathAbs)
            throw ArgErr(_msg_urlMustNotStartWithSlash("destUri", destUri, `my-app.zip`))

        bufferSize := 16*1024
        dstFile := (buildDir + destUri).normalize
        zip      := Zip.write(dstFile.out(false, bufferSize))
        // DO include the name of the containing folder in zip paths
        parentUri := toCompress.parent.uri
        // do NOT include the name of the containing folder in zip paths
        //parentUri := toCompress.isDir ? toCompress.uri : toCompress.parent.uri
        try {
            toCompress.walk |src| {
                if (src.isDir) return
                path := src.uri.relTo(parentUri)
                out  := zip.writeNext(path)
                try {
                    src.in(bufferSize).pipe(out)
                } finally
                    out.close
            }
        } finally
            zip.close

        return dstFile
    }

    ** Creates basic script files to launch the application.
    Void createScriptFiles(Str baseFileName, Str scriptArgs) {
        copyFile(findFile(`bin/fanlaunch`, true), `fanlaunch`)
        bshScript     := "#!/bin/bash\n\nexport FAN_HOME=.\nunset FAN_ENV\n. \"\${0%/*}/fanlaunch\"\nfanlaunch Fan ${scriptArgs} \"\$@\""
        bshFile     := (_distDir + `${baseFileName}`).normalize.out.writeChars(bshScript).close
        log("  - copied ${baseFileName}")
        cmdScript    := "@set FAN_HOME=.\n@set FAN_ENV=\n@java -cp \"%FAN_HOME%\\lib\\java\\sys.jar\" fanx.tools.Fan ${scriptArgs} %*"
        cmdFile     := (_distDir + `${baseFileName}.bat`).normalize.out.writeChars(cmdScript).close
        log("  - copied ${baseFileName}.bat")
    }

    ** Resolves a file based on the given relative URI.
    ** If 'useEnv' is 'true' then 'Env.cur.findFile(...)' is used to find the file, otherwise it is
    ** taken to be relative to 'fantomHomeDir'.
    File? findPodFile(Str podName, Bool checked := true) {
        if (useEnv)
            return Env.cur.findPodFile(podName) ?: (checked ? throw ArgErr("Could not find pod file for ${podName}") : null)
        file := (fantomHomeDir + `lib/fan/${podName}.pod`).normalize
        return file.exists ? file : (checked ? throw ArgErr("File not found - ${file}") : null)
    }

    ** Resolves a pod file based on its name.
    ** If 'useEnv' is 'true' then 'Env.cur.findFile(...)' is used to find the file, otherwise it is
    ** taken to be relative to 'fantomHomeDir'.
    File? findFile(Uri fileUri, Bool checked := true) {
        if (useEnv)
            return Env.cur.findFile(fileUri, checked)
        if (fileUri.isPathAbs)
            throw ArgErr(_msg_urlMustNotStartWithSlash("fileUri", fileUri, `etc/config.props`))
        file := (fantomHomeDir + fileUri).normalize
        return file.exists ? file : (checked ? throw ArgErr("File not found - ${file}") : null)
    }

    ** Echos the msg.
    static Void log(Obj? msg := "") {
        echo(msg?.toStr ?: "null")
    }

    private File? _copyFile(File? srcFile, Uri destUri, Bool overwrite, Str append) {
        if (!destUri.isPathOnly)
            throw ArgErr(_msg_urlMustBePathOnly("destUri", destUri, `etc/config.props`))
        if (destUri.isPathAbs)
            throw ArgErr(_msg_urlMustNotStartWithSlash("destUri", destUri, `etc/config.props`))

        if (srcFile == null)
            return null
        if (!srcFile.exists) {
            log("Src file does not exist: ${srcFile?.normalize?.osPath}")
            return null
        }

        if (destUri.isDir && !srcFile.isDir)
            destUri = destUri.plusName(srcFile.name)

        dstFile := (_distDir + destUri).normalize
        srcFile.copyTo(dstFile, ["overwrite": overwrite])
        log("  - copied " + dstFile.uri.relTo(_distDir.uri).toFile.osPath + append)
        return dstFile
    }

    private Str[] _findPodDependencies(Str[] podNames, Str podName) {
        if (!excludePods.contains(podName) && !podNames.contains(podName)) {
            podNames.add(podName)
            pod := Pod.find(podName)
            pod.depends.each |depend| {
                _findPodDependencies(podNames, depend.name)
            }
        }
        return podNames
    }

    private static Str _msg_urlMustBePathOnly(Str type, Uri url, Uri example) {
        "${type} URL `${url}` must ONLY be a path. e.g. `${example}`"
    }

    private static Str _msg_urlMustNotStartWithSlash(Str type, Uri url, Uri example) {
        "${type} URL `${url}` must NOT start with a slash. e.g. `${example}`"
    }
}

Have fun!


Discuss