Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: Main
on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
Expand Down Expand Up @@ -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-15
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
25 changes: 23 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-15
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
Expand Down Expand Up @@ -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
309 changes: 308 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,311 @@
" <!DOCTYPE svg" "* " 100

'';
version = "0.4.0";
# Builds a self-contained Scanline.app for distribution outside Nix.
# The derivation copies the runtime dylib closure into Contents/Frameworks,
# rewrites every /nix/store LC_LOAD_DYLIB to @rpath/<basename>, 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" <<PLIST_EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Scanline</string>
<key>CFBundleExecutable</key>
<string>Scanline</string>
<key>CFBundleIconFile</key>
<string>Scanline</string>
<key>CFBundleIdentifier</key>
<string>dev.skillless.Scanline</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Scanline</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$version</string>
<key>CFBundleVersion</key>
<string>$version</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Plex URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>plex</string>
</array>
</dict>
</array>
</dict>
</plist>
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";
Expand Down Expand Up @@ -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=";

Expand Down Expand Up @@ -206,5 +510,8 @@

packages.default = self.packages.${system}.scanline;
}
(pkgs.lib.optionalAttrs pkgs.stdenv.isDarwin {
packages.scanline-darwin-app = scanlineDarwinApp;
})
);
}
Loading