From 75b3be33a459fb93baef36e02a8c679f91cb440f Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Fri, 8 May 2026 14:42:39 +0200 Subject: [PATCH 1/3] Package Scanline as a self-contained macOS .app/.dmg --- flake.nix | 309 ++++++++++++++++++++++++++++++++++++++++++- scripts/build-dmg.sh | 46 +++++++ 2 files changed, 354 insertions(+), 1 deletion(-) create mode 100755 scripts/build-dmg.sh diff --git a/flake.nix b/flake.nix index 69e2995..90b99e8 100644 --- a/flake.nix +++ b/flake.nix @@ -64,7 +64,311 @@ " , and adds + # an LC_RPATH so the bundle resolves its own libraries from + # @executable_path/../Frameworks. The wrapper at Contents/MacOS/Scanline + # only needs to set the non-dylib runtime knobs (gst plugins, gio + # modules, gdk-pixbuf loader cache, schemas, icon themes). + scanlineDarwinApp = pkgs.runCommand "scanline-darwin-app" + { + inherit version; + src = pkgs.lib.cleanSource ./.; + inherit (self.packages.${system}) scanline; + nativeBuildInputs = with pkgs; [ + darwin.cctools + libicns + librsvg + glib.dev + gdk-pixbuf.dev + ]; + runtimeLibs = with pkgs; [ + gtk4 + libadwaita + glib.out + cairo + gdk-pixbuf + graphene + pango.out + libsecret + librsvg.out + appstream + ]; + gstPlugins = with pkgs.gst_all_1; [ + gstreamer + gst-plugins-base + gst-plugins-good + gst-plugins-bad + gst-plugins-ugly + gst-plugins-rs + gst-libav + ]; + glibNetworking = pkgs.glib-networking; + adwaitaIcons = pkgs.adwaita-icon-theme; + hicolorIcons = pkgs.hicolor-icon-theme; + librsvgPkg = pkgs.librsvg; + } + '' + set -e + APP="$out/Scanline.app" + mkdir -p "$APP/Contents/MacOS" + mkdir -p "$APP/Contents/Resources/glib-2.0/schemas" + mkdir -p "$APP/Contents/Resources/share/icons" + mkdir -p "$APP/Contents/Frameworks/gstreamer-1.0" + mkdir -p "$APP/Contents/Frameworks/gio/modules" + mkdir -p "$APP/Contents/Frameworks/gdk-pixbuf-2.0/2.10.0/loaders" + + # The closure has two libiconv.2.dylib files with incompatible ABIs: + # Apple's (no `_libiconv` symbol; gettext links against this) and GNU's + # (exports `_libiconv`; libidn2 + transitively libgnutls/libcurl/libpsl + # link against this). Flattening both to the same basename in Frameworks + # breaks whichever one loses the dedup race. Rename the GNU variant to + # libiconv-gnu.2.dylib so both can coexist. + dest_basename_for() { + local src="$1" + local base + base=$(basename "$src") + case "$base" in + libiconv.2.dylib|libiconv.dylib) + if nm "$src" 2>/dev/null | grep -q "T _libiconv$"; then + echo "libiconv-gnu.2.dylib" + return + fi + ;; + esac + echo "$base" + } + + # Copy a dylib + recursively bundle its /nix/store deps to Contents/Frameworks/. + # $1 = source dylib path, $2 = dest dir for THIS dylib (deps always go to Frameworks/). + copy_and_fix_dylib() { + local src="$1" + local dest_dir="$2" + local base + base=$(dest_basename_for "$src") + local dest="$dest_dir/$base" + [ -e "$dest" ] && return 0 + cp -L "$src" "$dest" + chmod +w "$dest" + install_name_tool -id "@rpath/$base" "$dest" 2>/dev/null || true + install_name_tool -add_rpath "@executable_path/../Frameworks" "$dest" 2>/dev/null || true + # Strip any /nix/store-prefixed LC_RPATHs the dylib carries; dyld searches + # rpaths in order, so a leftover /nix/store path would resolve before our + # @executable_path/../Frameworks and load from /nix/store on dev machines + # (and silently fail on user machines). + local rp + otool -l "$dest" | awk '/cmd LC_RPATH/{f=1} f && /path /{print $2; f=0}' | while IFS= read -r rp; do + case "$rp" in + /nix/store/*) + install_name_tool -delete_rpath "$rp" "$dest" 2>/dev/null || true + ;; + esac + done + local dep dep_base + while IFS= read -r dep; do + [ -z "$dep" ] && continue + case "$dep" in + /nix/store/*) + dep_base=$(dest_basename_for "$dep") + copy_and_fix_dylib "$dep" "$APP/Contents/Frameworks" + install_name_tool -change "$dep" "@rpath/$dep_base" "$dest" 2>/dev/null || true + ;; + esac + done < <(otool -L "$dest" | tail -n +2 | awk 'NF>0 {print $1}') + } + + # 1. Copy and fix the main binary. + # nixpkgs's wrapGAppsHook4 + wrapProgram chain produces a stack of + # tiny wrappers that exec the next, ending in the real Go binary at + # `..scanline-wrapped-wrapped`. The wrappers re-set DYLD env vars to + # /nix/store paths, so bundling the outer wrapper gives a non-relocatable + # bundle that only runs on machines where /nix/store exists. Skip the + # wrappers and bundle the unwrapped binary directly. + SOURCE_BIN="$scanline/bin/..scanline-wrapped-wrapped" + if [ ! -f "$SOURCE_BIN" ]; then + echo "Error: expected unwrapped binary at $SOURCE_BIN" >&2 + echo "wrapGAppsHook4 wrapping convention may have changed; contents of $scanline/bin:" >&2 + ls -la "$scanline/bin/" >&2 + exit 1 + fi + cp "$SOURCE_BIN" "$APP/Contents/MacOS/scanline-bin" + chmod +w "$APP/Contents/MacOS/scanline-bin" + install_name_tool -add_rpath "@executable_path/../Frameworks" \ + "$APP/Contents/MacOS/scanline-bin" 2>/dev/null || true + # Strip /nix/store-prefixed LC_RPATHs (e.g. gstreamer's lib dir baked in + # by buildGoModule), so dyld doesn't resolve @rpath against /nix/store first. + otool -l "$APP/Contents/MacOS/scanline-bin" \ + | awk '/cmd LC_RPATH/{f=1} f && /path /{print $2; f=0}' \ + | while IFS= read -r rp; do + case "$rp" in + /nix/store/*) + install_name_tool -delete_rpath "$rp" \ + "$APP/Contents/MacOS/scanline-bin" 2>/dev/null || true + ;; + esac + done + while IFS= read -r dep; do + [ -z "$dep" ] && continue + case "$dep" in + /nix/store/*) + dep_base=$(dest_basename_for "$dep") + copy_and_fix_dylib "$dep" "$APP/Contents/Frameworks" + install_name_tool -change "$dep" "@rpath/$dep_base" \ + "$APP/Contents/MacOS/scanline-bin" 2>/dev/null || true + ;; + esac + done < <(otool -L "$APP/Contents/MacOS/scanline-bin" | tail -n +2 | awk 'NF>0 {print $1}') + + # 2. Bundle puregotk-dlopened libs (not in the binary's LC_LOAD_DYLIB) + for pkg in $runtimeLibs; do + if [ -d "$pkg/lib" ]; then + for dylib in "$pkg/lib"/*.dylib; do + [ -f "$dylib" ] || continue + copy_and_fix_dylib "$dylib" "$APP/Contents/Frameworks" + done + fi + done + + # 3. GStreamer plugins + for pkg in $gstPlugins; do + if [ -d "$pkg/lib/gstreamer-1.0" ]; then + for plugin in "$pkg/lib/gstreamer-1.0"/*.dylib; do + [ -f "$plugin" ] || continue + copy_and_fix_dylib "$plugin" "$APP/Contents/Frameworks/gstreamer-1.0" + done + fi + done + + # 4. glib-networking GIO modules (TLS for libsoup). Note: glib uses + # .so extensions for loadable modules even on Darwin. + if [ -d "$glibNetworking/lib/gio/modules" ]; then + for mod in "$glibNetworking/lib/gio/modules"/*.so "$glibNetworking/lib/gio/modules"/*.dylib; do + [ -f "$mod" ] || continue + copy_and_fix_dylib "$mod" "$APP/Contents/Frameworks/gio/modules" + done + fi + + # 5. librsvg's GdkPixbuf loader (for SVG icons), then generate the + # loaders.cache template via gdk-pixbuf-query-loaders. The canonical tool + # reads each loader's metadata via dlopen, so the cache always matches + # what gdk-pixbuf actually expects. We replace the build-time .app path + # with @APPDIR@; the wrapper sed-substitutes it at launch. + for loader in "$librsvgPkg/lib/gdk-pixbuf-2.0/2.10.0/loaders"/*.dylib; do + [ -f "$loader" ] || continue + copy_and_fix_dylib "$loader" "$APP/Contents/Frameworks/gdk-pixbuf-2.0/2.10.0/loaders" + done + mkdir -p "$APP/Contents/Resources" + # Our copied loader has LC_RPATH @executable_path/../Frameworks; that's + # relative to scanline-bin at runtime but resolves to query-loaders' own + # /nix/store path here. Point dyld at our bundled Frameworks dir so it + # can resolve the loader's deps (including libiconv, which isn't in the + # devshell libraryPath) and successfully dlopen the loader. + DYLD_FALLBACK_LIBRARY_PATH="$APP/Contents/Frameworks" \ + gdk-pixbuf-query-loaders \ + "$APP/Contents/Frameworks/gdk-pixbuf-2.0/2.10.0/loaders"/*.dylib \ + | sed "s|$APP/Contents|@APPDIR@|g" \ + > "$APP/Contents/Resources/pixbuf-loaders.cache.in" + + # 6. GSettings schemas + cp "$src/assets/meta/dev.skillless.Scanline.gschema.xml" \ + "$APP/Contents/Resources/glib-2.0/schemas/" + glib-compile-schemas "$APP/Contents/Resources/glib-2.0/schemas/" + + # 7. Adwaita + hicolor icon themes + cp -R "$adwaitaIcons/share/icons/Adwaita" "$APP/Contents/Resources/share/icons/" + if [ -d "$hicolorIcons/share/icons/hicolor" ]; then + cp -R "$hicolorIcons/share/icons/hicolor" "$APP/Contents/Resources/share/icons/" + fi + + # 8. Scanline.icns from app.svg via rsvg-convert + png2icns + mkdir -p icns-staging + for sz in 16 32 64 128 256 512 1024; do + rsvg-convert -w "$sz" -h "$sz" "$src/assets/icons/app.svg" \ + -o "icns-staging/icon_$sz.png" + done + png2icns "$APP/Contents/Resources/Scanline.icns" \ + icns-staging/icon_16.png icns-staging/icon_32.png \ + icns-staging/icon_64.png icns-staging/icon_128.png \ + icns-staging/icon_256.png icns-staging/icon_512.png \ + icns-staging/icon_1024.png + + # 9. Info.plist (uses unquoted heredoc so $version is expanded by the build shell) + cat > "$APP/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Scanline + CFBundleExecutable + Scanline + CFBundleIconFile + Scanline + CFBundleIdentifier + dev.skillless.Scanline + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Scanline + CFBundlePackageType + APPL + CFBundleShortVersionString + $version + CFBundleVersion + $version + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + CFBundleURLTypes + + + CFBundleURLName + Plex URL + CFBundleURLSchemes + + plex + + + + + + PLIST_EOF + + # 10. Wrapper launcher (CFBundleExecutable). Quoted heredoc — no expansion at build time. + cat > "$APP/Contents/MacOS/Scanline" <<'WRAPPER_EOF' + #!/bin/bash + set -e + APP_DIR="$(cd "$(dirname "$0")/.." && pwd -P)" + APP_FW="$APP_DIR/Frameworks" + APP_RES="$APP_DIR/Resources" + + # GdkPixbuf loaders.cache: substitute @APPDIR@ in the build-time template + # (generated by gdk-pixbuf-query-loaders) with the actual install path. + PIXBUF_CACHE="$(mktemp -t scanline-pixbuf-XXXXXX)" + sed "s|@APPDIR@|$APP_DIR|g" "$APP_RES/pixbuf-loaders.cache.in" > "$PIXBUF_CACHE" + + # puregotk dlopens libgtk-4.1.dylib and friends by basename. dyld bare-name + # search ignores LC_RPATH, so DYLD_FALLBACK_LIBRARY_PATH is what makes it + # find our bundled libs. PUREGOTK_LIB_FOLDER is the puregotk-specific knob. + export DYLD_FALLBACK_LIBRARY_PATH="$APP_FW" + export PUREGOTK_LIB_FOLDER="$APP_FW" + export GDK_PIXBUF_MODULE_FILE="$PIXBUF_CACHE" + export GST_PLUGIN_PATH="$APP_FW/gstreamer-1.0" + export GIO_EXTRA_MODULES="$APP_FW/gio/modules" + export XDG_DATA_DIRS="$APP_RES/share" + export GSETTINGS_SCHEMA_DIR="$APP_RES/glib-2.0/schemas" + + exec "$(dirname "$0")/scanline-bin" "$@" + WRAPPER_EOF + chmod +x "$APP/Contents/MacOS/Scanline" + ''; in + pkgs.lib.recursiveUpdate { devShell = pkgs.mkShell ({ PUREGOTK_LIB_FOLDER = "${libraryPath}/lib"; @@ -130,7 +434,7 @@ packages.scanline = (pkgs.buildGoModule.override { go = pkgs.go_1_26; }) (finalAttrs: { pname = "scanline"; - version = "0.4.0"; + inherit version; src = pkgs.lib.cleanSource ./.; vendorHash = "sha256-RQn9pK/jfkzvpJTG9xADz91W40Ss19JEtZf+0N+zLUA="; @@ -206,5 +510,8 @@ packages.default = self.packages.${system}.scanline; } + (pkgs.lib.optionalAttrs pkgs.stdenv.isDarwin { + packages.scanline-darwin-app = scanlineDarwinApp; + }) ); } diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh new file mode 100755 index 0000000..c967295 --- /dev/null +++ b/scripts/build-dmg.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Packages a Scanline.app bundle into a drag-to-install .dmg. +# +# Usage: ./scripts/build-dmg.sh +# e.g. ./scripts/build-dmg.sh ./result/Scanline.app dev.skillless.Scanline.aarch64.dmg +# +# Stages the .app alongside an Applications symlink so users see the classic +# drag-to-install layout, then writes a UDZO-compressed DMG via hdiutil. +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +APP_PATH="$1" +DMG_PATH="$2" + +if [ ! -d "$APP_PATH" ]; then + echo "Error: $APP_PATH is not a directory" >&2 + exit 1 +fi + +if ! command -v hdiutil >/dev/null 2>&1; then + echo "Error: hdiutil not found (this script must run on macOS)" >&2 + exit 1 +fi + +STAGE_DIR=$(mktemp -d) +# Files copied from /nix/store are read-only, so rm -rf alone fails on them; +# strip write protection before cleanup. +trap 'chmod -R u+w "$STAGE_DIR" 2>/dev/null; rm -rf "$STAGE_DIR"' EXIT + +cp -R "$APP_PATH" "$STAGE_DIR/" +ln -s /Applications "$STAGE_DIR/Applications" + +rm -f "$DMG_PATH" +hdiutil create \ + -volname Scanline \ + -srcfolder "$STAGE_DIR" \ + -fs HFS+ \ + -format UDZO \ + -imagekey zlib-level=9 \ + "$DMG_PATH" + +echo "Wrote $DMG_PATH" From dea2b18c19eef3428e69e6246fb470030eb059b7 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Fri, 8 May 2026 14:42:41 +0200 Subject: [PATCH 2/3] Build macOS DMG in CI on tags and pushes to main --- .github/workflows/main.yml | 20 ++++++++++++++++++++ .github/workflows/release.yml | 25 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3e672b4..85f19d8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,6 +3,7 @@ name: Main on: push: branches: [main] + workflow_dispatch: permissions: contents: read @@ -37,3 +38,22 @@ jobs: manifest-path: dev.skillless.Scanline.json arch: ${{ matrix.target.arch }} cache-key: flatpak-builder-${{ matrix.target.arch }}-${{ github.sha }} + + macos-build: + name: macOS DMG (aarch64) + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + - name: Build .app + run: nix build .#scanline-darwin-app + - name: Build DMG + run: ./scripts/build-dmg.sh ./result/Scanline.app dev.skillless.Scanline.aarch64.dmg + - name: Upload Bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev.skillless.Scanline.aarch64.dmg + path: dev.skillless.Scanline.aarch64.dmg + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d03ae4..91b453a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,9 +44,28 @@ jobs: path: dev.skillless.Scanline.${{ matrix.target.arch }}.flatpak retention-days: 1 + macos-build: + name: macOS DMG (aarch64) + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + - name: Build .app + run: nix build .#scanline-darwin-app + - name: Build DMG + run: ./scripts/build-dmg.sh ./result/Scanline.app dev.skillless.Scanline.aarch64.dmg + - name: Upload Bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev.skillless.Scanline.aarch64.dmg + path: dev.skillless.Scanline.aarch64.dmg + retention-days: 1 + release: name: Create Release - needs: flatpak-build + needs: [flatpak-build, macos-build] runs-on: ubuntu-24.04 permissions: contents: write @@ -84,4 +103,6 @@ jobs: uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: body: ${{ steps.notes.outputs.body }} - files: '*.flatpak' + files: | + *.flatpak + *.dmg From 2200dd9f38a662bae2be54bd1dd9f3a019c5417d Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Fri, 8 May 2026 15:27:13 +0200 Subject: [PATCH 3/3] Target macOS 15 (Sequoia) as minimum supported version --- .github/workflows/main.yml | 2 +- .github/workflows/release.yml | 2 +- flake.nix | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 85f19d8..c5c0542 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: macos-build: name: macOS DMG (aarch64) - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91b453a..6334844 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: macos-build: name: macOS DMG (aarch64) - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/flake.nix b/flake.nix index 90b99e8..ad92b20 100644 --- a/flake.nix +++ b/flake.nix @@ -321,7 +321,7 @@ CFBundleVersion $version LSMinimumSystemVersion - 13.0 + 15.0 NSHighResolutionCapable CFBundleURLTypes