Subject: [PATCH] pack: Add support for AppImage pack format.

From: Noé Lopez <[email-masked]>
Date: Wed, 16 Oct 2024 23:51:30 +0200

From: Sebastian Dümcke <[email-masked]>

* guix/scripts/pack.scm: Add Appimage format.
* gnu/packages/appimage.scm
(gnu packages appimage): New module.
(fuse-for-appimage, squashfuse-for-appimage)
(appimage-type2-runtime): New variables.
* doc/guix.texi: Document AppImage pack.

Co-authored-by: Noé Lopez <[email-masked]>
Change-Id: I09f1241dfb9b267f94dce59914dea527d35ac60e
---
Hi,

This patch adds a new AppImage export format to « guix pack ».  This
enables guix users to easily share packets or environments with their
peers that don’t have guix via a statically linked, runs-everywhere
executable.

Sebastian and I co-authored this patch, I did the runtime packaging
and Sebastian did the actual command.  I’ve personally tested the
generated AppImages with my friend’s various distros, they work great!

Here are some cool examples to try (in ./pre-inst-env):
guix pack -f appimage -R --entry-point=bin/hello hello
guix pack -f appimage -R --entry-point=bin/openttd openttd
guix pack -f appimage -R --entry-point=bin/torbrowser torbrowser

Keep in mind the generated AppImage files don’t have execution
permission, so you need to copy them and chmod it :)

Have a great day,
Noé Lopez

 doc/guix.texi             |  56 +++++++++++-
 gnu/packages/appimage.scm | 179 ++++++++++++++++++++++++++++++++++++++
 guix/scripts/pack.scm     | 100 ++++++++++++++++++++-
 3 files changed, 333 insertions(+), 2 deletions(-)
 create mode 100644 gnu/packages/appimage.scm

diff --git a/doc/guix.texi b/doc/guix.texi
index b91d229d7c..327876c92f 100644
--- a/doc/guix.texi
+++ b/doc/guix.texi
@@ -6949,6 +6949,18 @@ Invoking guix pack
 environment}, using commands like @command{singularity shell} or
 @command{singularity exec}.
 
+@cindex AppImage, create an AppImage file with guix pack
+Another format internally based on SquashFS is
+@uref{https://appimage.org/, AppImage}. An AppImage file can be made
+executable and run without any special privileges:
+
+@example
+guix pack -f appimage --entry-point=bin/guile guile
+cp @var{file} . && chmod u+x $(basename @var{file})
+./$(basename @var{file}) --help
+@end example
+where @var{file} is the image returned by @command{guix pack}.
+
 Several command-line options allow you to customize your pack:
 
 @table @code
@@ -7065,6 +7077,48 @@ Invoking guix pack
 installation or other, non-rpm packs.
 @end quotation
 
+@item appimage
+@cindex AppImage, create an AppImage file with guix pack
+This produces an AppImage file with the @samp{.AppImage} extension.
+AppImage is a SquashFS volume prefixed with a runtime that mounts the
+SquashFS file system and executes the binary provided with
+@option{--entry-point}. This results in a self-contained archive that
+bundles the software and all its requirements into a single file. When
+the file is made executable it runs the packaged software.
+
+@example
+guix pack -f appimage --entry-point=bin/vlc vlc
+@end example
+
+The runtime used by AppImages makes use of libfuse to mount the image
+quickly.  If libfuse is not available, the AppImage can still be started
+using the @option{--appimage-extract-and-run} flag.
+
+@quotation Warning
+Unless @option{--relocatable} is used, the software will contain
+references to items inside the guix store, which are not present on
+systems without Guix. It is recommended to use @option{--relocatable}
+when distributing software to 3rd parties. A warning is printed when
+this option is not used.
+@end quotation
+
+@example
+guix pack -f appimage --entry-point=bin/hello --relocatable hello
+@end example
+
+@quotation Note
+The resulting AppImage does not conform to the complete standard as it
+currently does not contain a @code{.DirIcon} file. This does not impact
+functionality of the AppImage itself, but possibly that of software used
+to manage AppImages.
+@end quotation
+
+@quotation Note
+As the generated AppImage packages the complete dependency graph, it
+will be larger than comparable AppImage files found online, which depend
+on host system libraries.
+@end quotation
+
 @end table
 
 @cindex relocatable binaries
@@ -7155,7 +7209,7 @@ Invoking guix pack
 @cindex entry point, for Docker and Singularity images
 @item --entry-point=@var{command}
 Use @var{command} as the @dfn{entry point} of the resulting pack, if the pack
-format supports it---currently @code{docker} and @code{squashfs} (Singularity)
+format supports it---currently @code{docker}, @code{appimage} and @code{squashfs} (Singularity)
 support it.  @var{command} must be relative to the profile contained in the
 pack.
 
diff --git a/gnu/packages/appimage.scm b/gnu/packages/appimage.scm
new file mode 100644
index 0000000000..3bf9d84181
--- /dev/null
+++ b/gnu/packages/appimage.scm
@@ -0,0 +1,179 @@
+;;; GNU Guix --- Functional package management for GNU
+;;; Copyright © 2024 Noé Lopez <[email-masked]>
+;;;
+;;; This file is part of GNU Guix.
+;;;
+;;; GNU Guix is free software; you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation; either version 3 of the License, or (at
+;;; your option) any later version.
+;;;
+;;; GNU Guix is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;;; GNU General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.
+
+(define-module (gnu packages appimage)
+  #:use-module ((guix licenses) #:prefix license:)
+  #:use-module (gnu packages)
+  #:use-module (gnu packages base)
+  #:use-module (gnu packages compression)
+  #:use-module (gnu packages file-systems)
+  #:use-module (gnu packages linux)
+  #:use-module (guix build-system gnu)
+  #:use-module (guix download)
+  #:use-module (guix gexp)
+  #:use-module (guix git-download)
+  #:use-module (guix packages))
+
+(define fuse-for-appimage
+  (package
+    (inherit fuse)
+    (name "fuse")
+    (version "3.15.0")
+    (source
+     (origin
+       (method url-fetch)
+       (uri (string-append "https://github.com/libfuse/libfuse/releases/"
+                           "download/fuse-"
+                           version
+                           "/fuse-"
+                           version
+                           ".tar.xz"))
+       (sha256
+        (base32 "181cx8g40ki8m85j0pjw50kv4h733g08c769db6przqwbvyrqn3h"))))
+    (arguments
+     `(#:configure-flags ,#~(list (string-append "-Dudevrulesdir="
+                                                 #$output "/udev/rules.d")
+                                  "-Duseroot=false" "--default-library=static")
+       #:tests? #f
+       #:phases ,#~(modify-phases %standard-phases
+                     (add-after 'unpack 'set-file-names
+                       (lambda* (#:key inputs #:allow-other-keys)
+                         ;; libfuse calls out to mount(8) and umount(8).  Make sure
+                         ;; it refers to the right ones.
+                         (substitute* '("lib/mount_util.c")
+                           (("/bin/(u?)mount" _ maybe-u)
+                            (search-input-file inputs
+                                               (string-append "bin/" maybe-u
+                                                              "mount"))))
+                         (substitute* '("util/mount.fuse.c")
+                           (("/bin/sh")
+                            (search-input-file inputs "/bin/sh")))
+
+                         ;; This hack leads libfuse to search for 'fusermount' in
+                         ;; $PATH, where it may find a setuid-root binary, instead of
+                         ;; trying solely $out/sbin/fusermount and failing because
+                         ;; it's not setuid.
+                         (substitute* "lib/meson.build"
+                           (("-DFUSERMOUNT_DIR=[[:graph:]]+")
+                            "-DFUSERMOUNT_DIR=\"/var/empty\"'"))))
+                     (add-after 'unpack 'fix-install
+                       (lambda* (#:key inputs #:allow-other-keys)
+                         (substitute* '("util/meson.build")
+                           (("install_helper.sh")
+                            "true"))
+                         (substitute* '("util/meson.build")
+                           (("fuseconf_path = .*")
+                            "fuseconf_path = '/etc/fuse.conf'"))))
+                     (add-before 'configure 'set-paths
+                       (lambda* (#:key inputs outputs #:allow-other-keys)
+                         (let ((dummy-init.d (string-append (getcwd)
+                                                            "/etc/init.d")))
+                           (setenv "MOUNT_FUSE_PATH"
+                                   (string-append #$output "/sbin"))
+                           (setenv "UDEV_RULES_PATH"
+                                   (string-append #$output "/lib/udev/rules.d"))))))))))
+
+(define squashfuse-for-appimage
+  (let ((revision "0")
+        (commit "e51978cd6bb5c4d16fae9eee43d0b258f570bb0f"))
+    (package
+      (inherit squashfuse)
+      (name "squashfuse")
+      (version (git-version "0.1.104" revision commit))
+      (source
+       (origin
+         (method git-fetch)
+         (uri (git-reference
+               (url "https://github.com/vasi/squashfuse")
+               (commit commit)))
+         (file-name (git-file-name name version))
+         (sha256
+          (base32 "02421zsgcvbip5ff999ci4c748zxsc34yycjfp7rq8afrlnlvl29"))))
+      (arguments
+       (list
+        #:configure-flags #~'("CFLAGS=-ffunction-sections -fdata-sections -Os -no-pie"
+                              "LDFLAGS=-static")
+        #:phases #~(modify-phases %standard-phases
+                     (add-after 'install 'install-private-headers
+                       (lambda _
+                         (install-file "fuseprivate.h"
+                                       (string-append #$output
+                                                      "/include/squashfuse/")))))))
+      (inputs (list fuse-for-appimage
+                    `(,zstd "lib")
+                    `(,zstd "static")
+                    `(,zlib "out")
+                    `(,zlib "static"))))))
+
+(define-public appimage-type2-runtime
+  (let ((revision "0")
+        (commit "47b665594856b4e8928f8932adcf6d13061d8c30"))
+    (package
+      (name "appimage-type2-runtime")
+      (version (git-version "continuous" revision commit))
+      (source
+       (origin
+         (method git-fetch)
+         (uri (git-reference
+               (url "https://github.com/AppImage/type2-runtime")
+               (commit commit)))
+         (file-name (git-file-name name version))
+         (sha256
+          (base32 "0954crhlbapxis96g1s0vfpf78ybr64zvjalak387ksxj560g44x"))))
+      (arguments
+       (list
+        #:make-flags #~(list "-Csrc/runtime" "runtime-fuse3"
+                             (string-append "CFLAGS="
+                                            "-I"
+                                            #$fuse-2
+                                            "/include/fuse/"
+                                            " -DGIT_COMMIT='\""
+                                            "guix-"
+                                            #$version
+                                            "\"'"
+                                            " -D_FILE_OFFSET_BITS=64"
+                                            " -static"))
+        #:phases #~(modify-phases %standard-phases
+                     (delete 'configure)
+                     (delete 'check)
+                     (replace 'install
+                       (lambda _
+                         (install-file "src/runtime/runtime-fuse3"
+                                       (string-append #$output "/bin"))))
+                     ;; must be after all elf reliant phases
+                     (add-after 'make-dynamic-linker-cache 'set-magic-bytes
+                       (lambda _
+                         (use-modules (ice-9 binary-ports))
+                         (let ((port (open (string-append #$output
+                                            "/bin/runtime-fuse3")
+                                           (logior O_WRONLY))))
+                           (seek port 8 SEEK_SET)
+                           (put-bytevector port #vu8(#x41 #x49 #x02))
+                           (close-port port)))))
+        #:disallowed-references (list squashfuse-for-appimage
+                                      fuse-for-appimage zstd zlib)))
+      ;; only needed at build time
+      (inputs (list squashfuse-for-appimage fuse-for-appimage
+                    `(,zstd "static")
+                    `(,zlib "static")))
+      (build-system gnu-build-system)
+      (home-page "https://github.com/AppImage/type2-runtime")
+      (synopsis "Runtime for AppImages")
+      (description "The runtime is the executable part of every AppImage.  It
+mounts the payload via FUSE and executes the entrypoint.")
+      (license license:expat))))
diff --git a/guix/scripts/pack.scm b/guix/scripts/pack.scm
index 7c5fe76fe0..f517100fcf 100644
--- a/guix/scripts/pack.scm
+++ b/guix/scripts/pack.scm
@@ -10,6 +10,8 @@
 ;;; Copyright © 2022 Alex Griffin <[email-masked]>
 ;;; Copyright © 2023 Graham James Addis <[email-masked]>
 ;;; Copyright © 2023 Oleg Pykhalov <[email-masked]>
+;;; Copyright © 2024 Sebastian Dümcke <[email-masked]>
+;;; Copyright © 2024 Noé Lopez <[email-masked]>
 ;;;
 ;;; This file is part of GNU Guix.
 ;;;
@@ -56,6 +58,7 @@ (define-module (guix scripts pack)
   #:use-module ((gnu packages compression) #:hide (zip))
   #:use-module (gnu packages guile)
   #:use-module (gnu packages base)
+  #:autoload   (gnu packages appimage) (appimage-type2-runtime)
   #:autoload   (gnu packages gnupg) (guile-gcrypt)
   #:autoload   (gnu packages guile) (guile2.0-json guile-json)
   #:use-module (srfi srfi-1)
@@ -64,6 +67,7 @@ (define-module (guix scripts pack)
   #:use-module (srfi srfi-35)
   #:use-module (srfi srfi-37)
   #:use-module (ice-9 match)
+  #:use-module (ice-9 optargs)
   #:export (symlink-spec-option-parser
 
             self-contained-tarball
@@ -71,6 +75,7 @@ (define-module (guix scripts pack)
             rpm-archive
             docker-image
             squashfs-image
+            self-contained-appimage
 
             %formats
             guix-pack))
@@ -974,8 +979,96 @@ (define* (rpm-archive name profile
   (gexp->derivation (string-append name ".rpm") build
                     #:target target
                     #:references-graphs `(("profile" ,profile))))
+
+;;;
+;;; AppImage format
+;;;
+(define* (self-contained-appimage name profile
+                                  #:key target
+                                  (profile-name "guix-profile")
+                                  entry-point
+                                  (compressor "zstd")
+                                  localstatedir?
+                                  (symlinks '())
+                                  (archiver tar)
+                                  (extra-options '()))
+  "Return a self-contained AppImage containing a store initialized with the
+closure of PROFILE, a derivation.  The AppImage contains /gnu/store unless
+RELOCATABLE option is used; if LOCALSTATEDIR? is true, it also contains
+/var/guix, including /var/guix/db with a properly initialized store database.
+
+SYMLINKS must be a list of (SOURCE -> TARGET) tuples denoting symlinks to be
+added to the pack."
+  (unless entry-point
+    (leave (G_ "entry-point must be provided in the '~a' format~%")
+           'appimage))
+  (let-keywords extra-options #f ((relocatable? #f))
+    (unless relocatable?
+      (warning (G_ "AppImages should be built with the --relocatable flag~%"))))
+
+  (define runtime-package appimage-type2-runtime)
+  (define runtime-path "bin/runtime-fuse3")
+  (define %valid-compressors '("gzip" "zstd"))
+
+  (let ((compressor-name (compressor-name compressor)))
+    (unless (member compressor-name %valid-compressors)
+      (leave (G_ "~a is not a valid squashfs archive compressor used in
+generating the AppImage.  Valid compressors are: ~a~%")
+             compressor-name
+             %valid-compressors)))
 
-  
+  (define builder
+    (with-extensions (list guile-gcrypt)
+      (with-imported-modules (source-module-closure
+                              '((guix build store-copy)
+                                (guix build utils))
+                              #:select? not-config?)
+        #~(begin
+            (use-modules (guix build utils)
+                         (guix build store-copy)
+                         (rnrs io ports)
+                         (srfi srfi-1)
+                         (srfi srfi-26))
+
+            (define (concatenate result file1 file2)
+              (let ((p (open-file-output-port result))
+                    (f1 (open-file-input-port file1))
+                    (f2 (open-file-input-port file2)))
+                (put-bytevector p (get-bytevector-all f1))
+                (close-port f1)
+                (put-bytevector p (get-bytevector-all f2))
+                (close-port f2)
+                (close-port p)))
+
+            (let* ((appdir "AppDir")
+                   (squashfs "squashfs")
+                   (profile-items (map store-info-item
+                                       (call-with-input-file "profile" read-reference-graph)))
+                   (profile-path (find (cut (file-name-predicate "profile$") <> #f) profile-items)))
+              (mkdir-p appdir)
+              ;; copy all store items from the profile to the AppDir
+              (for-each (lambda (f)
+                          (copy-store-item f appdir)) profile-items)
+              ;; symlink the provided entry-point to AppDir/AppRun 
+              (symlink (string-append "." profile-path "/" #$entry-point)
+                       (string-append appdir "/AppRun"))
+              ;; create .desktop file as required by the spec
+              (make-desktop-entry-file
+               (string-append appdir "/" #$name ".desktop")
+               #:name #$name
+               #:exec #$entry-point)
+              ;; compress the AppDir
+              (invoke #+(file-append squashfs-tools "/bin/mksquashfs") appdir
+                      squashfs "-root-owned" "-noappend"
+                      "-comp" #+(compressor-name compressor))
+              ;; append runtime and squashFS into file AppImage
+              (concatenate #$output
+                           #$(file-append runtime-package "/" runtime-path)
+                           squashfs))))))
+  (gexp->derivation (string-append name ".AppImage") builder
+		    #:target target
+		    #:references-graphs `(("profile" ,profile))))
+
 ;;;
 ;;; Compiling C programs.
 ;;;
@@ -1311,6 +1404,7 @@ (define %formats
     (squashfs . ,squashfs-image)
     (docker  . ,docker-image)
     (deb . ,debian-archive)
+    (appimage . ,self-contained-appimage)
     (rpm . ,rpm-archive)))
 
 (define (show-formats)
@@ -1327,6 +1421,8 @@ (define (show-formats)
   deb           Debian archive installable via dpkg/apt"))
   (display (G_ "
   rpm           RPM archive installable via rpm/yum"))
+  (display (G_ "
+  appimage      AppImage self-contained and executable format"))
   (newline))
 
 (define (required-option symbol)
@@ -1694,6 +1790,8 @@ (define-command (guix-pack . args)
                                            (process-file-arg opts 'preun-file)
                                            #:postun-file
                                            (process-file-arg opts 'postun-file)))
+                                    ('appimage
+                                     (list #:relocatable? relocatable?))
                                     (_ '())))
                    (target      (assoc-ref opts 'target))
                    (bootstrap?  (assoc-ref opts 'bootstrap?))

base-commit: d95588242c605fbb72e25fe36a0903a1538e9018
-- 
2.46.0