A shepherd for your Appimages.
0

Configure Feed

Select the types of activity you want to include in your feed.

add dwarfs support

Aly Raffauf (Jun 18, 2026, 12:49 PM EDT) 05a3f36a 9bf2e199

+146 -21
+1
.gitignore
··· 5 5 AppDir/ 6 6 /appherder 7 7 /result 8 + /vendor
+1
flake.nix
··· 32 32 default = pkgs.mkShellNoCC { 33 33 packages = with pkgs; [ 34 34 go 35 + dwarfs 35 36 self.formatter.${system} 36 37 ]; 37 38 };
+70 -17
internal/appherder/appimage.go
··· 14 14 "github.com/CalebQ42/squashfs" 15 15 ) 16 16 17 - // openAppImage exposes a type-2 AppImage's squashfs payload as an fs.FS without 18 - // executing it. The caller must invoke the returned closer to release the file. 17 + // openAppImage exposes a type-2 AppImage's filesystem as an fs.FS. Supports 18 + // SquashFS (in-process) and DwarFS (extracted via the runtime). Caller must 19 + // invoke the returned closer to release resources. 19 20 func openAppImage(path string) (fs.FS, func(), error) { 20 21 file, err := os.Open(path) 21 22 if err != nil { 22 23 return nil, nil, fmt.Errorf("open AppImage %s: %w", path, err) 23 24 } 24 25 25 - offset, err := appImageSquashfsOffset(file) 26 + offset, err := fileSystemOffset(file) 26 27 if err != nil { 27 28 file.Close() 28 29 return nil, nil, fmt.Errorf("read AppImage %s: %w", path, err) 29 30 } 30 31 32 + if isDwarFS(file, offset) { 33 + file.Close() 34 + return openDwarFS(path) 35 + } 36 + 37 + if isSquashFS(file, offset) { 38 + return openSquashFS(file, offset) 39 + } 40 + 41 + // Not at the expected offset — try scanning forward. 42 + if scanned, ok := scanForSquashFS(file, offset); ok { 43 + return openSquashFS(file, scanned) 44 + } 45 + 46 + file.Close() 47 + return nil, nil, fmt.Errorf("read AppImage %s: unknown or unsupported filesystem", path) 48 + } 49 + 50 + func openSquashFS(file *os.File, offset int64) (fs.FS, func(), error) { 31 51 reader, err := squashfs.NewReaderAtOffset(file, offset) 32 52 if err != nil { 33 53 file.Close() 34 - return nil, nil, fmt.Errorf("read AppImage filesystem %s: %w", path, err) 54 + return nil, nil, fmt.Errorf("read SquashFS at offset %d: %w", offset, err) 35 55 } 36 - 37 56 return squashFS{root: &reader.FS}, func() { file.Close() }, nil 38 57 } 39 58 ··· 68 87 func (s squashFS) Glob(pattern string) ([]string, error) { return s.root.Glob(pattern) } 69 88 func (s squashFS) Stat(name string) (fs.FileInfo, error) { return s.root.Stat(name) } 70 89 71 - // appImageSquashfsOffset returns the byte offset of the squashfs image appended 72 - // to the AppImage's ELF runtime, computed from the ELF section header table. 73 - func appImageSquashfsOffset(reader io.ReaderAt) (int64, error) { 90 + // fileSystemOffset returns the byte offset where the AppImage's payload 91 + // filesystem begins, computed from the ELF section header table. 92 + func fileSystemOffset(file io.ReaderAt) (int64, error) { 74 93 var header [64]byte 75 - if _, err := reader.ReadAt(header[:], 0); err != nil { 94 + if _, err := file.ReadAt(header[:], 0); err != nil { 76 95 return 0, fmt.Errorf("read ELF header: %w", err) 77 96 } 78 97 if header[0] != 0x7f || header[1] != 'E' || header[2] != 'L' || header[3] != 'F' { 79 98 return 0, errors.New("not an AppImage (missing ELF header)") 80 99 } 81 - // AppImages record their type in the otherwise-unused ELF padding bytes. 100 + 101 + // Byte 8-10: AppImage type. 1 = ISO 9660 (unsupported), 2 = appended filesystem. 82 102 if header[8] == 'A' && header[9] == 'I' && header[10] == 1 { 83 103 return 0, errors.New("type-1 AppImages are not supported") 84 104 } 85 105 86 - byteOrder := binary.ByteOrder(binary.LittleEndian) 106 + var endian binary.ByteOrder = binary.LittleEndian 87 107 if header[5] == 2 { 88 - byteOrder = binary.BigEndian 108 + endian = binary.BigEndian 89 109 } 90 - // The squashfs starts where the ELF ends: e_shoff + e_shnum*e_shentsize. 110 + 111 + // e_shoff + e_shnum * e_shentsize = end of the section header table. 91 112 switch header[4] { 92 - case 1: // 32-bit ELF: e_shoff@32, e_shentsize@46, e_shnum@48 93 - return int64(byteOrder.Uint32(header[32:36])) + int64(byteOrder.Uint16(header[46:48]))*int64(byteOrder.Uint16(header[48:50])), nil 94 - case 2: // 64-bit ELF: e_shoff@40, e_shentsize@58, e_shnum@60 95 - return int64(byteOrder.Uint64(header[40:48])) + int64(byteOrder.Uint16(header[58:60]))*int64(byteOrder.Uint16(header[60:62])), nil 113 + case 1: // 32-bit 114 + tableStart := int64(endian.Uint32(header[32:36])) 115 + entrySize := int64(endian.Uint16(header[46:48])) 116 + entryCount := int64(endian.Uint16(header[48:50])) 117 + return tableStart + entrySize*entryCount, nil 118 + case 2: // 64-bit 119 + tableStart := int64(endian.Uint64(header[40:48])) 120 + entrySize := int64(endian.Uint16(header[58:60])) 121 + entryCount := int64(endian.Uint16(header[60:62])) 122 + return tableStart + entrySize*entryCount, nil 96 123 default: 97 124 return 0, errors.New("unknown ELF class") 98 125 } 126 + } 127 + 128 + // isSquashFS reports whether a SquashFS superblock begins at offset. 129 + func isSquashFS(file interface{ ReadAt([]byte, int64) (int, error) }, offset int64) bool { 130 + magic := make([]byte, 4) 131 + _, err := file.ReadAt(magic, offset) 132 + return err == nil && string(magic) == "hsqs" 133 + } 134 + 135 + // scanForSquashFS searches for a SquashFS superblock in 4096-byte steps over 136 + // the next 64 MiB starting from offset. Returns the found position or false. 137 + func scanForSquashFS(file interface{ ReadAt([]byte, int64) (int, error) }, offset int64) (int64, bool) { 138 + const window = 64 * 1024 * 1024 139 + buf := make([]byte, 4096) 140 + for pos := offset; pos < offset+window; pos += 4096 { 141 + bytesRead, err := file.ReadAt(buf, pos) 142 + if err != nil { 143 + return 0, false 144 + } 145 + for i := range bytesRead - 4 { 146 + if buf[i] == 'h' && buf[i+1] == 's' && buf[i+2] == 'q' && buf[i+3] == 's' { 147 + return pos + int64(i), true 148 + } 149 + } 150 + } 151 + return 0, false 99 152 } 100 153 101 154 func (a App) installAppImage(file string, appName string) (string, error) {
+3 -3
internal/appherder/appimage_test.go
··· 20 20 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x38, 0x00, 0x0a, 0x00, 0x40, 0x00, 21 21 0x1e, 0x00, 0x1d, 0x00, 22 22 } 23 - got, err := appImageSquashfsOffset(bytes.NewReader(header)) 23 + got, err := fileSystemOffset(bytes.NewReader(header)) 24 24 if err != nil { 25 25 t.Fatal(err) 26 26 } ··· 30 30 } 31 31 32 32 func TestAppImageSquashfsOffsetRejectsNonELF(t *testing.T) { 33 - if _, err := appImageSquashfsOffset(bytes.NewReader(make([]byte, 64))); err == nil { 33 + if _, err := fileSystemOffset(bytes.NewReader(make([]byte, 64))); err == nil { 34 34 t.Fatal("expected error for non-ELF input") 35 35 } 36 36 } ··· 39 39 h := make([]byte, 64) 40 40 copy(h, []byte{0x7f, 'E', 'L', 'F'}) 41 41 h[8], h[9], h[10] = 'A', 'I', 1 42 - if _, err := appImageSquashfsOffset(bytes.NewReader(h)); err == nil { 42 + if _, err := fileSystemOffset(bytes.NewReader(h)); err == nil { 43 43 t.Fatal("expected error for type-1 AppImage") 44 44 } 45 45 }
+63
internal/appherder/dwarfs.go
··· 1 + package appherder 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io/fs" 7 + "os" 8 + "os/exec" 9 + "path/filepath" 10 + "time" 11 + ) 12 + 13 + // openDwarFS extracts the DwarFS payload from an AppImage. Tries the system 14 + // dwarfsextract tool first; falls back to the AppImage's own --appimage-extract 15 + // as a last resort. Returns the extracted filesystem and a cleanup function. 16 + func openDwarFS(appimagePath string) (fs.FS, func(), error) { 17 + dir, err := os.MkdirTemp("", "appherder-dwarfs") 18 + if err != nil { 19 + return nil, nil, fmt.Errorf("create temp directory: %w", err) 20 + } 21 + cleanup := func() { os.RemoveAll(dir) } 22 + 23 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 24 + defer cancel() 25 + 26 + cmd := extractCommand(ctx, appimagePath, dir) 27 + if out, err := cmd.CombinedOutput(); err != nil { 28 + cleanup() 29 + return nil, nil, fmt.Errorf("extract DwarFS AppImage: %w\n%s", err, out) 30 + } 31 + 32 + // dwarfsextract -o extracts directly; --appimage-extract nests under squashfs-root. 33 + root := dir 34 + if _, err := os.Stat(filepath.Join(dir, "squashfs-root")); err == nil { 35 + root = filepath.Join(dir, "squashfs-root") 36 + } 37 + return os.DirFS(root), cleanup, nil 38 + } 39 + 40 + func extractCommand(ctx context.Context, appimagePath, destDir string) *exec.Cmd { 41 + extract, err := exec.LookPath("dwarfsextract") 42 + if err == nil { 43 + // Extract only the desktop entry and icon files. 44 + return exec.CommandContext(ctx, extract, 45 + "--input="+appimagePath, 46 + "--output="+destDir, 47 + "--pattern=**.desktop", 48 + "--pattern=**.png", 49 + "--pattern=**.svg", 50 + "--pattern=.DirIcon", 51 + ) 52 + } 53 + cmd := exec.CommandContext(ctx, appimagePath, "--appimage-extract") 54 + cmd.Dir = destDir 55 + return cmd 56 + } 57 + 58 + // isDwarFS reports whether the payload at offset starts with the DwarFS magic. 59 + func isDwarFS(file interface{ ReadAt([]byte, int64) (int, error) }, offset int64) bool { 60 + magic := make([]byte, 6) 61 + _, err := file.ReadAt(magic, offset) 62 + return err == nil && string(magic) == "DWARFS" 63 + }
+8 -1
package.nix
··· 1 1 { 2 2 buildGoModule, 3 + dwarfs, 4 + lib, 5 + makeWrapper, 3 6 }: let 4 7 version = "dev"; 5 8 in ··· 7 10 pname = "appherder"; 8 11 inherit version; 9 12 src = ./.; 10 - vendorHash = "sha256-hjyL3/01LWN2VTEBMwJmUyTi+tfh4zMVPWiNTkzCWfk="; 13 + vendorHash = "sha256-DW+OYl2Lr7j4ZGOD/Cml2/2yuauX4EudLRaYH15YtAA="; 11 14 subPackages = ["cmd/appherder"]; 12 15 ldflags = ["-X main.version=${version}"]; 16 + nativeBuildInputs = [makeWrapper]; 17 + postInstall = '' 18 + wrapProgram $out/bin/appherder --prefix PATH : ${lib.makeBinPath [dwarfs]} 19 + ''; 13 20 }