chore: remove libheif dependency; use sips for HEIC conversion

Replace the `github.com/strukturag/libheif-go` CGo dependency with
macOS-native `sips` for HEIC/AVIF wallpaper thumbnail conversion.

- Drop `libheif` from go.mod, Makefile, Goreleaser config, and CI
- Remove `cgo` build tag requirement from wallpaper package
- Implement `convertWithSips()` using temp files and the `sips` CLI
- Refactor thumbnail decoding into `extractThumbnailPreview()` helper
- Update tests to cover sips-based HEIC conversion path
This commit is contained in:
blacktop
2026-04-12 11:14:01 -06:00
parent f4d623ecc0
commit 5cdbab9f1e
9 changed files with 170 additions and 95 deletions
-1
View File
@@ -137,7 +137,6 @@ jobs:
cd build
cmake ..
sudo make install
sudo apt-get install -yq --fix-missing libheif1 libheif-plugin-libde265 heif-gdk-pixbuf libheif-dev # for wallpaper downloader
- name: Build Dependencies (macOS)
if: matrix.platform == 'macos-13'
run: |
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# mkdir /tmp/homebrew
# cd /tmp; curl -L https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C homebrew
# sudo mv /tmp/homebrew /usr/local/homebrew
# arch -x86_64 /usr/local/homebrew/bin/brew install --quiet unicorn libusb libheif pkg-config
# arch -x86_64 /usr/local/homebrew/bin/brew install --quiet unicorn libusb pkg-config
# env:
# HOMEBREW_NO_AUTO_UPDATE: 1
-1
View File
@@ -405,7 +405,6 @@ brews:
type: optional
- name: git-delta
type: optional
- name: libheif
conflicts:
- ipsw-frida
install: |
+1 -1
View File
@@ -22,7 +22,7 @@ x86-brew: ## Install the x86_64 homebrew on Apple Silicon
mkdir /tmp/homebrew
cd /tmp; curl -L https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C homebrew
sudo mv /tmp/homebrew /usr/local/homebrew
arch -x86_64 /usr/local/homebrew/bin/brew install unicorn libusb libheif
arch -x86_64 /usr/local/homebrew/bin/brew install unicorn libusb
.PHONY: setup
setup: build-deps dev-deps ## Install all the build and dev dependencies
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build darwin && cgo && wallpaper
//go:build darwin && wallpaper
/*
Copyright © 2026 blacktop
+1 -2
View File
@@ -50,7 +50,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gocolly/colly/v2 v2.3.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
github.com/google/gousb v1.1.3
github.com/google/uuid v1.6.0
github.com/hashicorp/go-version v1.9.0
@@ -70,7 +70,6 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15
github.com/twmb/murmur3 v1.1.8
github.com/ulikunitz/xz v0.5.15
github.com/unicorn-engine/unicorn v0.0.0-20260217064959-7c5db94191de
+2 -4
View File
@@ -423,8 +423,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8=
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -810,8 +810,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15 h1:aFa2PvtQulG5uVQ8adH84JCwRZ2rjiZnRUU/mWxJRG8=
github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15/go.mod h1:ZW0m/zWIvFqFSpPdiWRje8xdwyWJqt3Cnt6bVlDti8g=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+88 -50
View File
@@ -1,21 +1,22 @@
//go:build darwin && cgo && wallpaper
//go:build darwin && wallpaper
package wallpaper
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/blacktop/go-plist"
"github.com/blacktop/ipsw/internal/download"
"github.com/disintegration/imaging"
_ "github.com/strukturag/libheif-go"
)
const (
@@ -54,6 +55,85 @@ type MESUAssets struct {
SigningKey string `plist:"SigningKey,omitempty"`
}
func resizeJPEGThumbnail(imgBytes []byte) ([]byte, error) {
img, err := jpeg.Decode(bytes.NewReader(imgBytes))
if err != nil {
return nil, fmt.Errorf("unable to decode thumbnail.jpg: %v", err)
}
resizedImg := imaging.Resize(img, 0, 700, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, resizedImg, &jpeg.Options{Quality: 90}); err != nil {
return nil, fmt.Errorf("unable to encode resized thumbnail: %v", err)
}
return buf.Bytes(), nil
}
func resizePNGThumbnail(imgBytes []byte) ([]byte, error) {
img, err := png.Decode(bytes.NewReader(imgBytes))
if err != nil {
return nil, fmt.Errorf("unable to decode thumbnail.png: %v", err)
}
resizedImg := imaging.Resize(img, 0, 700, imaging.Lanczos)
var buf bytes.Buffer
if err := png.Encode(&buf, resizedImg); err != nil {
return nil, fmt.Errorf("unable to encode resized thumbnail: %v", err)
}
return buf.Bytes(), nil
}
// convertWithSips uses macOS ImageIO via sips for HEIC-family wallpaper previews.
func convertWithSips(imgBytes []byte, sourceExt string) ([]byte, error) {
tempDir, err := os.MkdirTemp("", "ipsw-wallpaper-*")
if err != nil {
return nil, fmt.Errorf("unable to create temporary directory for %s preview: %w", sourceExt, err)
}
defer os.RemoveAll(tempDir)
sourcePath := filepath.Join(tempDir, "thumbnail"+sourceExt)
if err := os.WriteFile(sourcePath, imgBytes, 0o600); err != nil {
return nil, fmt.Errorf("unable to write temporary %s preview: %w", sourceExt, err)
}
outputPath := filepath.Join(tempDir, "thumbnail.png")
cmd := exec.Command("sips", "--resampleHeight", "700", "-s", "format", "png", sourcePath, "--out", outputPath)
if output, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("sips failed to convert %s preview: %w (%s)", sourceExt, err, strings.TrimSpace(string(output)))
}
converted, err := os.ReadFile(outputPath)
if err != nil {
return nil, fmt.Errorf("unable to read converted %s preview: %w", sourceExt, err)
}
return converted, nil
}
func extractThumbnailPreview(imgBytes []byte, thumbnailFile string) ([]byte, error) {
thumbnailExt := strings.ToLower(filepath.Ext(thumbnailFile))
if bytes.HasPrefix(imgBytes, []byte("\x89PNG")) {
thumbnailExt = ".png"
}
switch thumbnailExt {
case ".avif", ".heic", ".heif":
converted, err := convertWithSips(imgBytes, thumbnailExt)
if err != nil {
return nil, fmt.Errorf("unable to decode %s preview: %w", thumbnailExt, err)
}
return converted, nil
case ".jpeg", ".jpg":
return resizeJPEGThumbnail(imgBytes)
case ".png":
return resizePNGThumbnail(imgBytes)
default:
return nil, fmt.Errorf("unsupported thumbnail format: %s", thumbnailExt)
}
}
// FetchWallpaperPlist fetches and decodes the Apple wallpaper plist
func FetchWallpaperPlist() (*MESUAssets, error) {
resp, err := http.Get(updateURL)
@@ -72,7 +152,7 @@ func FetchWallpaperPlist() (*MESUAssets, error) {
return &assets, nil
}
// ExtractThumbnailBytes downloads a wallpaper zip from the given URL and extracts the thumbnail.jpg to a byte slice,
// ExtractThumbnailBytes downloads a wallpaper zip from the given URL and extracts the thumbnail image to a byte slice,
// resizing it to a fixed height while preserving aspect ratio.
func ExtractThumbnailBytes(url, proxy string, insecure bool) ([]byte, error) {
zr, err := download.NewRemoteZipReader(url, &download.RemoteConfig{
@@ -118,58 +198,16 @@ func ExtractThumbnailBytes(url, proxy string, insecure bool) ([]byte, error) {
if filepath.Base(f.Name) == thumbnailFile {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("unable to open thumbnail.jpg: %v", err)
return nil, fmt.Errorf("unable to open thumbnail image %s: %v", thumbnailFile, err)
}
defer rc.Close()
imgBytes, err := io.ReadAll(rc)
if err != nil {
return nil, fmt.Errorf("unable to read thumbnail.jpg: %v", err)
}
if bytes.HasPrefix(imgBytes, []byte("\x89PNG")) {
thumbnailFile = "thumbnail.png"
}
switch filepath.Ext(thumbnailFile) {
case ".heic":
img, format, err := image.Decode(bytes.NewReader(imgBytes))
if err != nil {
return nil, fmt.Errorf("unable to decode thumbnail.heic: %v", err)
}
if format != "heif" && format != "heic" && format != "avif" {
return nil, fmt.Errorf("unsupported thumbnail format: %s", format)
}
resizedImg := imaging.Resize(img, 0, 700, imaging.Lanczos)
var buf bytes.Buffer
if err := png.Encode(&buf, resizedImg); err != nil {
return nil, fmt.Errorf("could not encode image as PNG: %w", err)
}
return buf.Bytes(), nil
case ".jpeg", ".jpg":
img, err := jpeg.Decode(bytes.NewReader(imgBytes))
if err != nil {
return nil, fmt.Errorf("unable to decode thumbnail.jpg: %v", err)
}
resizedImg := imaging.Resize(img, 0, 700, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, resizedImg, &jpeg.Options{Quality: 90}); err != nil {
return nil, fmt.Errorf("unable to encode resized thumbnail: %v", err)
}
return buf.Bytes(), nil
case ".png":
img, err := png.Decode(bytes.NewReader(imgBytes))
if err != nil {
return nil, fmt.Errorf("unable to decode thumbnail.jpg: %v", err)
}
resizedImg := imaging.Resize(img, 0, 700, imaging.Lanczos)
var buf bytes.Buffer
if err := png.Encode(&buf, resizedImg); err != nil {
return nil, fmt.Errorf("unable to encode resized thumbnail: %v", err)
}
return buf.Bytes(), nil
default:
return nil, fmt.Errorf("unsupported thumbnail format: %s", filepath.Ext(thumbnailFile))
return nil, fmt.Errorf("unable to read thumbnail image %s: %v", thumbnailFile, err)
}
return extractThumbnailPreview(imgBytes, thumbnailFile)
}
}
return nil, fmt.Errorf("unable to find thumbnail.jpg in wallpaper zip")
return nil, fmt.Errorf("unable to find thumbnail image in wallpaper zip")
}
+76 -34
View File
@@ -1,45 +1,87 @@
//go:build darwin && cgo && wallpaper
//go:build darwin && wallpaper
package wallpaper
import (
"reflect"
"bytes"
"image"
"image/color"
"image/png"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestExtractThumbnailBytes(t *testing.T) {
type args struct {
url string
proxy string
insecure bool
t.Helper()
got, err := ExtractThumbnailBytes(
"https://updates.cdn-apple.com/2022/mobileassets/012-19617/B488E2A1-B291-4E42-AD9A-7111CB03A2AB/com_apple_MobileAsset_Wallpaper/605957001046c16663cb44a4b4ba12c3bcc9281b.zip",
"",
false,
)
if err != nil {
t.Fatalf("ExtractThumbnailBytes() error = %v", err)
}
tests := []struct {
name string
args args
want []byte
wantErr bool
}{
{
name: "test",
args: args{
url: "https://updates.cdn-apple.com/2022/mobileassets/012-19617/B488E2A1-B291-4E42-AD9A-7111CB03A2AB/com_apple_MobileAsset_Wallpaper/605957001046c16663cb44a4b4ba12c3bcc9281b.zip",
proxy: "",
insecure: false,
},
want: nil,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ExtractThumbnailBytes(tt.args.url, tt.args.proxy, tt.args.insecure)
if (err != nil) != tt.wantErr {
t.Errorf("ExtractThumbnailBytes() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ExtractThumbnailBytes() = %v, want %v", got, tt.want)
}
})
if len(got) == 0 {
t.Fatal("ExtractThumbnailBytes() returned no thumbnail bytes")
}
}
func TestConvertWithSipsHEIC(t *testing.T) {
if _, err := exec.LookPath("sips"); err != nil {
t.Skipf("sips not available: %v", err)
}
tempDir := t.TempDir()
pngPath := filepath.Join(tempDir, "source.png")
heicPath := filepath.Join(tempDir, "source.heic")
img := image.NewNRGBA(image.Rect(0, 0, 2, 3))
for y := 0; y < 3; y++ {
for x := 0; x < 2; x++ {
img.Set(x, y, color.NRGBA{R: uint8(40 * x), G: uint8(60 * y), B: 200, A: 255})
}
}
var pngBytes bytes.Buffer
if err := png.Encode(&pngBytes, img); err != nil {
t.Fatalf("png.Encode() error = %v", err)
}
if err := os.WriteFile(pngPath, pngBytes.Bytes(), 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
cmd := exec.Command("sips", "-s", "format", "heic", pngPath, "--out", heicPath)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("sips HEIC encode failed: %v (%s)", err, bytes.TrimSpace(output))
}
heicBytes, err := os.ReadFile(heicPath)
if err != nil {
t.Fatalf("os.ReadFile() error = %v", err)
}
converted, err := convertWithSips(heicBytes, ".heic")
if err != nil {
t.Fatalf("convertWithSips() error = %v", err)
}
if len(converted) == 0 {
t.Fatal("convertWithSips() returned no bytes")
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(converted))
if err != nil {
t.Fatalf("image.DecodeConfig() error = %v", err)
}
if format != "png" {
t.Fatalf("image.DecodeConfig() format = %s, want png", format)
}
if cfg.Height != 700 {
t.Fatalf("converted height = %d, want 700", cfg.Height)
}
if cfg.Width == 0 {
t.Fatal("converted width = 0")
}
}