Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions LocalPreferences.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ app_description = "A longer description of the app"
windowed = false
compress = true
selfsign = false
skipsign = false
overwrite_target = false

msix_path_length_threshold = 260
Expand All @@ -20,6 +21,9 @@ dmg_hardened_runtime = true
dmg_sandboxed_runtime = false
dmg_compression = "lzma"

appimage_compression = "zstd"


bundler = "juliaimg"

juliaimg_mainless = false
Expand Down
130 changes: 130 additions & 0 deletions docs/src/appimage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# AppImage

An AppImage is a single executable file that is **mounted** rather than installed. For a Julia
application this matters more than it might seem: a bundled distribution is tens of thousands of
small files, and writing all of them onto a network filesystem is the slow part of every other
format. On HPC clusters that cost — sometimes called the hydration problem — is what stops people
distributing Julia applications as archives at all. An AppImage never unpacks, so it does not pay it.

```
appbundler build . --target-bundle=appimage --build-dir=build
```

produces `build/<app>-<version>-<arch>.AppImage`, which a user makes executable and runs. There is
nothing to install and nothing to clean up afterwards.

## How the format works

An AppImage is a **runtime ELF followed by a squashfs image**. The runtime knows the filesystem
begins immediately after itself, mounts it through FUSE, and executes `AppRun` from the mount
point. That is the whole format — there is no container, no manifest, and no package database.

AppBundler stages an AppDir, then has `mksquashfs` write the filesystem straight into the output
file after a reserved prefix, and finally writes the runtime into that prefix. Building the
squashfs separately and concatenating would need a second full-size temporary copy, which for a
Julia distribution is hundreds of megabytes.

The staged AppDir looks like this:

```
AppRun entry point, execs bin/julia
<app>.desktop Icon=<app>, no path, no extension
<app>.png
.DirIcon what file managers read for the thumbnail
usr/share/applications/<app>.desktop menu integration once installed
usr/share/icons/hicolor/256x256/apps/<app>.png
usr/share/metainfo/<id>.appdata.xml AppStream metadata
bin/ lib/ share/ etc/ the Julia distribution
```

The payload is compressed with **zstd** by default, and `gzip` is the only other choice. The
runtime bundles squashfuse built against zstd and zlib alone, so an image compressed any other way
cannot be mounted — `mksquashfs` happily produces `xz` and `lz4` images, and the runtime answers
`Failed to extract AppImage` for both. AppBundler therefore rejects them at configuration time
rather than letting you ship an AppImage nobody can run. Of the two that work, zstd decompresses
far faster, which is the point of mounting rather than unpacking.

## Obtaining the runtime

The runtime is a ~900 KB static binary. There is no jll for it yet — packaging one means first
packaging `libfuse` and `squashfuse`, neither of which exists in Yggdrasil — so for now point
AppBundler at a runtime you obtained yourself:

```toml
# LocalPreferences.toml
[AppBundler]
appimage_runtime = "/path/to/runtime-x86_64"
```

or pass it directly:

```julia
AppImage(app_dir; runtime = "/path/to/runtime-x86_64")
```

Signed runtimes are published at
[AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases). Prefer a dated tag
over `continuous` so your builds stay reproducible.

Once `AppImageRuntime_jll` is registered, leaving `appimage_runtime` empty will pick it up
automatically. Note that a jll provides the artifact for the host platform, so building an AppImage
for another architecture still needs an explicit path.

## Build-time disk space

The AppDir is staged uncompressed before it is packed, so building an AppImage of a Julia
distribution needs a couple of gigabytes of working space. Julia stages into `TMPDIR`, which on
many systems is a `tmpfs` sized well below that, and `mksquashfs` reports exhaustion only as
`FATAL ERROR: Probably out of space on output filesystem`. If you hit that, point `TMPDIR` at a
real filesystem:

```
TMPDIR=/var/tmp appbundler build . --target-bundle=appimage --build-dir=build
```

## Where the depot goes

The mounted filesystem is read-only and disappears when the application exits, so the Julia depot
has to live somewhere else. Two options, selected with the `appimage_depot` preference:

- **`"app"`** (default) — `AppRun` points `USER_DATA` at `$XDG_DATA_HOME/<app>`, falling back to
`~/.local/share/<app>`. The application gets a persistent per-user depot and the host's `~/.julia`
is never touched.
- **`"julia"`** — the stock depot is left as Julia found it, with the bundled `share/julia`
appended so the shipped packages stay resolvable. Choose this where users expect the application
to see the environments they already have, which is common on HPC.

## FUSE

Mounting needs `fusermount` on the machine running the AppImage. Where it is missing the runtime
says so and suggests `--appimage-extract-and-run`, which extracts to a temporary directory first —
correct, but it gives up the property that made the format attractive.

If your target machines lack FUSE, the payload can be read directly, since it is an ordinary
squashfs at a known offset:

```julia
using AppBundler
AppBundler.AppImagePack.unpack("MyApp-1.0.0-x86_64.AppImage", "extracted/")
```

or, outside Julia:

```
unsquashfs -o $(./MyApp-1.0.0-x86_64.AppImage --appimage-offset) -d extracted MyApp-1.0.0-x86_64.AppImage
```

`--appimage-offset` and `AppBundler.AppImagePack.offset` return the same number: the size of the
runtime prefix.

## API

```@docs
AppBundler.AppImagePack
AppBundler.AppImagePack.pack
AppBundler.AppImagePack.offset
AppBundler.AppImagePack.unpack
AppBundler.AppImageRuntime
AppBundler.AppImageRuntime.resolve
AppBundler.bundle(::AppBundler.JuliaImgBundle, ::AppBundler.AppImage, ::String)
```
14 changes: 10 additions & 4 deletions docs/src/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@ Both specs can be staged directly into a directory for inspection before packagi

## Bundle Formats

A bundle format defines the packaging target. The three supported formats are `DMG` (macOS), `MSIX` (Windows), and `Snap` (Linux). They are instantiated from a project directory:
A bundle format defines the packaging target. The supported formats are `DMG` (macOS), `MSIX` (Windows), and `Snap` and `AppImage` (Linux). They are instantiated from a project directory:

```julia
dmg = DMG(project; arch = Sys.ARCH, kwargs...)
msix = MSIX(project; arch = Sys.ARCH, kwargs...)
snap = Snap(project; arch = Sys.ARCH, kwargs...)
dmg = DMG(project; arch = Sys.ARCH, kwargs...)
msix = MSIX(project; arch = Sys.ARCH, kwargs...)
snap = Snap(project; arch = Sys.ARCH, kwargs...)
appimage = AppImage(project; arch = Sys.ARCH, kwargs...)
```

`AppImage` produces a single mountable file rather than an installed tree; see [AppImage](@ref).

Each format reads configuration file overrides from the corresponding `project/meta/<format>` directory and carries architecture information that determines the destination platform. Bundle formats can also be staged independently via `stage(format, destination)` to produce the directory structure before compression and signing.

### Low-Level Bundle API
Expand All @@ -51,6 +54,7 @@ AppBundler.JuliaCBundle
AppBundler.DMG
AppBundler.MSIX
AppBundler.Snap
AppBundler.AppImage
```

## Functions
Expand All @@ -60,5 +64,7 @@ AppBundler.stage(::AppBundler.JuliaImg.JuliaImgBundle, ::String)
AppBundler.stage(::AppBundler.JuliaC.JuliaCBundle, ::String)
AppBundler.stage(::AppBundler.MSIX, ::String)
AppBundler.bundle(::Function, ::AppBundler.DMG, ::String)
AppBundler.bundle(::Function, ::AppBundler.MSIX, ::String)
AppBundler.bundle(::AppBundler.JuliaC.JuliaCBundle, ::AppBundler.AppImage, ::String)
AppBundler.bundle(::AppBundler.JuliaImgBundle, ::AppBundler.DMG, ::String)
```
8 changes: 8 additions & 0 deletions recipes/appimage/AppRun.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash
# Generic entry point for a non-Julia payload.

if [ -z "${APPDIR}" ]; then
APPDIR=$(cd -P "$(dirname "$(readlink -f "$0")")" >/dev/null 2>&1 && pwd)
fi

exec "${APPDIR}/bin/{{APP_NAME}}" "$@"
20 changes: 20 additions & 0 deletions recipes/appimage/juliaimg_AppRun.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/bin/bash
# Entry point the AppImage runtime executes after mounting the bundled filesystem.
#
# $APPDIR is set by the runtime and points at the mount point. It is absent when the AppDir is
# executed directly (during a build, or after --appimage-extract), so fall back to resolving it
# from this script's own location.

if [ -z "${APPDIR}" ]; then
APPDIR=$(cd -P "$(dirname "$(readlink -f "$0")")" >/dev/null 2>&1 && pwd)
fi

{{#APP_DEPOT}}
# Persist the depot under a per-user directory rather than the mount point, which is read-only and
# disappears when the AppImage exits. AppEnv reads USER_DATA in startup.jl to place the depot.
if [ -z "${USER_DATA}" ]; then
export USER_DATA="${XDG_DATA_HOME:-${HOME}/.local/share}/{{APP_NAME}}"
fi
{{/APP_DEPOT}}

exec "${APPDIR}/bin/julia" {{#MODULE_NAME}}--eval="using {{MODULE_NAME}}" -- {{/MODULE_NAME}}"$@"
9 changes: 9 additions & 0 deletions recipes/appimage/main.desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[Desktop Entry]
Name={{APP_DISPLAY_NAME}}
Exec={{APP_NAME}}
Icon={{APP_NAME}}
Version={{APP_VERSION}}
Comment={{APP_SUMMARY}}
Terminal={{#WINDOWED}}false{{/WINDOWED}}{{^WINDOWED}}true{{/WINDOWED}}
Type=Application
Categories=Utility;
16 changes: 16 additions & 0 deletions recipes/appimage/metainfo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>{{BUNDLE_IDENTIFIER}}</id>
<name>{{APP_DISPLAY_NAME}}</name>
<summary>{{APP_SUMMARY}}</summary>
<description>
<p>{{APP_DESCRIPTION}}</p>
</description>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary</project_license>
<developer_name>{{PUBLISHER_DISPLAY_NAME}}</developer_name>
<launchable type="desktop-id">{{APP_NAME}}.desktop</launchable>
<releases>
<release version="{{APP_VERSION}}"/>
</releases>
</component>
2 changes: 2 additions & 0 deletions src/AppBundler.jl
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ include("DMG/HFS.jl")
include("DMG/DMGPack.jl")

include("Snap/SnapPack.jl")
include("AppImage/AppImageRuntime.jl")
include("AppImage/AppImagePack.jl")

include("MSIX/MSIXPack.jl")
include("MSIX/MSIXIcons.jl")
Expand Down
Loading
Loading