macOS: osxcross packaging path (scripts/package.sh mac → .app .zip)

Add a `mac` target that cross-compiles an Apple binary via osxcross
(MAC_ARCH=arm64 default, or x86_64), assembles an unsigned yt-offline.app
(Info.plist + Mach-O + best-effort .icns via png2icns), and zips it. The
crypto stack is ring, which builds against the osxcross SDK cleanly.

- Sets the per-target linker + cc-rs CC/CXX/AR env from the osxcross
  wrappers (oa64-clang / o64-clang); discovers the versioned ar triple.
- Gated on the toolchain being present: `all` folds it in only when an
  Apple rust target + an osxcross wrapper + zip exist, and a bare `mac`
  run skips cleanly (exit 0) otherwise — verified.
- Local-only, not in CI: the macOS SDK can't be hosted in a public image.
- Refresh the stale Windows + macOS sections of docs/PACKAGING.md (they
  still described the pre-cfg-gating blocked state) and the CI section;
  note a possible future MacPorts port. ROADMAP 3.1 updated.

Untested end-to-end (no osxcross/SDK on this box); the scaffolding + skip
path are verified and `all` on a stock box is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-16 03:09:30 -07:00
parent 207013e957
commit 983864f421
3 changed files with 217 additions and 38 deletions

View file

@ -154,13 +154,19 @@ Linux; `disk_space` (`statvfs`), `plex` (symlinks, with a Windows
`symlink_file` path), the mpv IPC `UnixStream`, and the `symlink_file` path), the mpv IPC `UnixStream`, and the
`chmod`/`PermissionsExt` guards are all `cfg(unix)`-gated. `chmod`/`PermissionsExt` guards are all `cfg(unix)`-gated.
**macOS is deferred, not blocked.** The code is Unix and cfg-gated, so it **macOS has a packaging path, pending the toolchain.** `scripts/package.sh
*should* compile on a Mac unchanged, but it can't be linked from the mac` cross-compiles an Apple target (`MAC_ARCH=arm64` default, or
Linux host (no osxcross/SDK) and Codeberg has no macOS runners — so a real `x86_64`) via osxcross, assembles an unsigned `yt-offline.app`
tested `.app`/binary needs either a GitHub Actions `macos-latest` runner (`Info.plist` + Mach-O + best-effort `.icns`), and zips it. The crypto
(mirror CI there) or a local osxcross setup. Remaining for either OS: a stack is `ring`, which builds against the osxcross SDK fine. It's gated on
real per-OS tray backend (e.g. `tray-icon`) if the tray is wanted off the operator having osxcross built with a macOS SDK on PATH (the SDK comes
Linux, and on-hardware runtime testing. from Xcode and can't be redistributed, so this stays **local-only, not in
CI** — `all` skips it cleanly when the toolchain is absent). Untested
end-to-end here because this box has no osxcross/SDK. Remaining: actually
run it through osxcross + on-hardware testing; codesigning/notarization
and a `.dmg`; and a real macOS tray backend (e.g. `tray-icon` — the tray
is a no-op off Linux today). A GitHub Actions `macos-latest` job is the
alternative that also unlocks signing.
### 3.2 Android client ### 3.2 Android client

View file

@ -69,34 +69,67 @@ For repeated builds after pushing new commits, always pass `-C`
(cleanbuild) so makepkg re-checks out the latest source instead of (cleanbuild) so makepkg re-checks out the latest source instead of
reusing a stale cached clone. reusing a stale cached clone.
## Windows — experimental ## Windows (.zip)
Windows is **not** currently a first-class target. Two Linux-only Windows cross-compiles cleanly from Linux — the formerly-blocking
dependencies block a clean `--target x86_64-pc-windows-gnu` build: Linux-only deps are target-gated in `Cargo.toml` (`ksni` and `rfd`'s
xdg-portal backend are `cfg(target_os = "linux")`; `tray::start` is a
no-op off Linux; `rfd` falls back to its native Win32 backend).
- **`ksni`** (system tray) — talks to the freedesktop StatusNotifierItem ```sh
D-Bus spec, which doesn't exist on Windows. Needs replacing with rustup target add x86_64-pc-windows-gnu
`tray-icon` behind `#[cfg(windows)]`, or stubbing `src/tray.rs` to a sudo apt install mingw-w64 zip # or your distro's equivalent
no-op on non-Unix. scripts/package.sh win
- **`rfd` xdg-portal backend** — the file picker uses the XDG desktop ```
portal. The `rfd` crate does support a native Windows backend, but the
feature flags in `Cargo.toml` would need to be made target-conditional.
Once those are addressed, the rest (eframe, axum, rusqlite-bundled) is This produces `dist/yt-offline-<ver>-x86_64-windows.zip` containing
already cross-platform — `bundled_ytdlp_path()` and friends already have `yt-offline.exe`, `LICENSE.txt`, and a `README.txt` listing the runtime
`cfg!(windows)` branches. The path to a Windows `.exe`/`.msi` (via PATH deps (yt-dlp / ffmpeg / mpv). Release builds link as a GUI-subsystem
[`cargo-wix`](https://github.com/volks73/cargo-wix)) is then mechanical. app (no console window); the binary reattaches to the launching terminal
at runtime (`attach_windows_console` in `main.rs`) so `yt-offline.exe
--web 8080` from PowerShell prints its logs, while a double-click stays
windowless.
Tracked as a follow-up; PRs welcome. `scripts/package.sh all` includes the Windows zip automatically when the
target + mingw + zip are all present, and silently skips it otherwise.
## macOS ## macOS (.app .zip)
Same shape as Windows: the tray needs a macOS backend. eframe runs fine macOS cross-compiles via [osxcross](https://github.com/tpoechtrager/osxcross),
on macOS otherwise. A `.app` bundle + `.dmg` would follow once the tray which needs Apple's macOS SDK (extracted from Xcode — its license does not
is abstracted behind a trait with per-OS implementations. permit redistribution, which is why this build is **local-only and not in
CI**). The crypto stack is `ring`, which builds against the osxcross SDK
without trouble.
One-time setup: build osxcross with a macOS SDK and put its
`target/bin` on your `PATH` (this provides the `oa64-clang` / `o64-clang`
wrapper compilers). Then:
```sh
rustup target add aarch64-apple-darwin # or x86_64-apple-darwin
MAC_ARCH=arm64 scripts/package.sh mac # arm64 (default) | x86_64
```
This cross-compiles, assembles an unsigned `yt-offline.app`
(`Info.plist` + the Mach-O binary; an `.icns` icon too if `png2icns` is
available), and zips it to `dist/yt-offline-<ver>-<arch>-macos.zip`. The
`.app` is **not codesigned or notarized**, so on the target Mac it must be
opened the first time via right-click → Open, or cleared with `xattr -dr
com.apple.quarantine yt-offline.app`. Runtime deps (yt-dlp / ffmpeg / mpv)
are expected on PATH as on the other platforms.
A `.dmg` and codesigning/notarization (and a `tray-icon`-based macOS tray)
are follow-ups; the tray is currently a no-op off Linux. A **MacPorts**
port (a `Portfile` building from source, like the Arch `PKGBUILD`) is a
possible future native-distribution path — unscheduled.
## CI ## CI
`.forgejo/workflows/release.yml` runs `scripts/package.sh all` on every `.forgejo/workflows/release.yml` runs `scripts/package.sh all` on every
pushed tag (`v*`) and attaches the resulting `.deb`/`.rpm`/`.AppImage` to pushed tag (`v*`) and attaches the resulting artifacts to the Codeberg
the Codeberg release. See that file for the runner setup. release. The Linux container also installs `mingw-w64` + `zip` + the
`x86_64-pc-windows-gnu` target, so the **Windows zip is built in CI**
alongside the `.deb`/`.rpm`/`.AppImage`. The **macOS zip is not in CI**
(the SDK can't be hosted in a public image) — build it locally per the
section above, or add a GitHub Actions `macos-latest` job which has Xcode
and can also codesign/notarize.

View file

@ -2,7 +2,7 @@
# Build distributable packages for yt-offline. # Build distributable packages for yt-offline.
# #
# Usage: # Usage:
# scripts/package.sh [deb|rpm|appimage|win|all] # scripts/package.sh [deb|rpm|appimage|win|mac|all]
# #
# With no argument, builds everything that's buildable on the current host. # With no argument, builds everything that's buildable on the current host.
# Output lands in dist/. Each format is independent — a failure in one # Output lands in dist/. Each format is independent — a failure in one
@ -13,11 +13,20 @@
# - .rpm → cargo-generate-rpm (cargo install cargo-generate-rpm) # - .rpm → cargo-generate-rpm (cargo install cargo-generate-rpm)
# - AppImage → appimagetool (downloaded to dist/tools/ if missing) # - AppImage → appimagetool (downloaded to dist/tools/ if missing)
# - win .zip → x86_64-pc-windows-gnu target + mingw-w64 gcc + zip # - win .zip → x86_64-pc-windows-gnu target + mingw-w64 gcc + zip
# - mac .zip → {aarch64,x86_64}-apple-darwin target + osxcross + zip
# #
# The Linux release binary is built once up front and reused by the Linux # The Linux release binary is built once up front and reused by the Linux
# formats; the Windows .zip cross-compiles a separate target on demand. # formats; the Windows/macOS .zips cross-compile separate targets on demand.
# `all` skips the Windows zip unless the cross toolchain is present, so a # `all` skips the Windows and macOS zips unless their cross toolchains are
# plain `all` on a stock Linux box still succeeds. # present, so a plain `all` on a stock Linux box still succeeds.
#
# macOS notes: cross-compiling for Apple targets needs osxcross
# (https://github.com/tpoechtrager/osxcross) built with a macOS SDK, and its
# wrapper compilers (oa64-clang / o64-clang) on PATH. Set MAC_ARCH=arm64
# (default) or x86_64. The result is an unsigned yt-offline.app inside a .zip;
# it is *not* codesigned or notarized, so on the target Mac it must be opened
# via right-click → Open (or `xattr -dr com.apple.quarantine`) the first time.
# See docs/PACKAGING.md.
set -uo pipefail set -uo pipefail
@ -221,10 +230,139 @@ win_available() {
&& command -v zip >/dev/null 2>&1 && command -v zip >/dev/null 2>&1
} }
# ── macOS .app .zip (cross-compiled via osxcross) ────────────────────────────
# Builds an unsigned yt-offline.app for one Apple arch (MAC_ARCH=arm64 default,
# or x86_64) and zips it. Requires osxcross's wrapper compilers on PATH; the
# crypto stack is `ring`, which cross-builds against osxcross's SDK fine. Like
# the other formats we don't bundle yt-dlp/ffmpeg/mpv — the .app's README and
# Info.plist note they're expected on PATH. Skips cleanly if the toolchain is
# absent so `all` on a stock Linux box is unaffected.
mac_target_for() { # arch → rust target triple
case "$1" in
arm64|aarch64) echo "aarch64-apple-darwin" ;;
x86_64|x64) echo "x86_64-apple-darwin" ;;
*) echo "" ;;
esac
}
mac_wrapper_for() { # arch → osxcross clang wrapper name
case "$1" in
arm64|aarch64) echo "oa64-clang" ;;
x86_64|x64) echo "o64-clang" ;;
*) echo "" ;;
esac
}
mac_available() {
local arch="${MAC_ARCH:-arm64}"
local target wrapper
target="$(mac_target_for "$arch")"; wrapper="$(mac_wrapper_for "$arch")"
[[ -n "$target" ]] \
&& rustup target list --installed 2>/dev/null | grep -qx "$target" \
&& command -v "$wrapper" >/dev/null 2>&1 \
&& command -v zip >/dev/null 2>&1
}
build_mac() {
local arch="${MAC_ARCH:-arm64}"
local target wrapper
target="$(mac_target_for "$arch")"; wrapper="$(mac_wrapper_for "$arch")"
say "building macOS .app .zip ($arch${target:-?})"
if [[ -z "$target" ]]; then
RESULT[mac]="fail unknown MAC_ARCH '$arch' (want arm64 or x86_64)"; return
fi
if ! rustup target list --installed 2>/dev/null | grep -qx "$target"; then
RESULT[mac]="skip rust target $target not installed (rustup target add $target)"; return
fi
if ! command -v "$wrapper" >/dev/null 2>&1; then
RESULT[mac]="skip osxcross $wrapper not on PATH (build osxcross, add target/bin to PATH)"; return
fi
if ! command -v zip >/dev/null 2>&1; then
RESULT[mac]="skip zip not found"; return
fi
# Point cargo + cc-rs at the osxcross wrappers for this target. cargo reads
# the per-target linker from CARGO_TARGET_<TRIPLE>_LINKER (triple
# upper-cased, '-'→'_'); cc-rs (rusqlite-bundled, ring) reads CC/CXX/AR_<triple>.
local bindir; bindir="$(dirname "$(command -v "$wrapper")")"
local cxx="${wrapper%clang}clang++" # oa64-clang → oa64-clang++
# ar/ranlib are only published under the versioned triple (e.g.
# aarch64-apple-darwin23-ar); discover it, fall back to llvm-ar.
local ver_triple ar ranlib
ver_triple="$(basename "$(ls "$bindir/${target%-darwin}-darwin"*-ar 2>/dev/null | head -1)" 2>/dev/null | sed 's/-ar$//')"
if [[ -n "$ver_triple" && -x "$bindir/${ver_triple}-ar" ]]; then
ar="$bindir/${ver_triple}-ar"; ranlib="$bindir/${ver_triple}-ranlib"
else
ar="$(command -v llvm-ar || echo ar)"; ranlib="$(command -v llvm-ranlib || echo ranlib)"
fi
local cargo_var="CARGO_TARGET_$(echo "$target" | tr 'a-z-' 'A-Z_')_LINKER"
if ! env \
"$cargo_var=$wrapper" \
"CC_${target//-/_}=$wrapper" \
"CXX_${target//-/_}=$cxx" \
"AR_${target//-/_}=$ar" \
"RANLIB_${target//-/_}=$ranlib" \
cargo build --release --target "$target"; then
RESULT[mac]="fail cargo build --target $target returned nonzero"; return
fi
local bin="target/$target/release/$PKG"
if [[ ! -f "$bin" ]]; then
RESULT[mac]="fail expected $bin after build"; return
fi
# ── Assemble the .app bundle (just a directory tree + Info.plist). ──
local app="$DIST/${PKG}.app"
rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
install -m755 "$bin" "$app/Contents/MacOS/$PKG"
cat > "$app/Contents/Info.plist" <<PLIST
<?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>CFBundleName</key> <string>yt-offline</string>
<key>CFBundleDisplayName</key> <string>yt-offline</string>
<key>CFBundleIdentifier</key> <string>org.codeberg.yt-offline</string>
<key>CFBundleVersion</key> <string>${VERSION}</string>
<key>CFBundleShortVersionString</key> <string>${VERSION}</string>
<key>CFBundleExecutable</key> <string>${PKG}</string>
<key>CFBundleIconFile</key> <string>${PKG}.icns</string>
<key>CFBundlePackageType</key> <string>APPL</string>
<key>LSMinimumSystemVersion</key> <string>11.0</string>
<key>NSHighResolutionCapable</key> <true/>
</dict>
</plist>
PLIST
# Best-effort icon: convert icon.png → .icns if a converter exists; the
# bundle is valid without it (just falls back to a generic icon).
if command -v png2icns >/dev/null 2>&1; then
png2icns "$app/Contents/Resources/$PKG.icns" icon.png >/dev/null 2>&1 \
|| warn "png2icns failed; .app will use a generic icon"
elif command -v iconutil >/dev/null 2>&1; then
: # iconutil is macOS-only; on a real Mac you'd build an .iconset here
else
warn "no png2icns/iconutil; .app ships without a custom icon"
fi
local out="$DIST/${PKG}-${VERSION}-${arch}-macos.zip"
rm -f "$out"
# Zip the .app from DIST so the archive root is the bundle. -y keeps any
# symlinks as links; -r recurses the bundle tree.
if (cd "$DIST" && zip -q -r -y "$out" "${PKG}.app"); then
RESULT[mac]="ok $out"
else
RESULT[mac]="fail zip returned nonzero"
fi
rm -rf "$app"
}
# ── Dispatch ───────────────────────────────────────────────────────────────── # ── Dispatch ─────────────────────────────────────────────────────────────────
# The Windows zip doesn't need the Linux release binary, so skip that slow # The Windows/macOS zips don't need the Linux release binary, so skip that
# build when Windows is all that was asked for. # slow build when a cross target is all that was asked for.
if [[ "$WANT" != "win" ]]; then if [[ "$WANT" != "win" && "$WANT" != "mac" ]]; then
build_binary build_binary
fi fi
@ -233,16 +371,18 @@ case "$WANT" in
rpm) build_rpm ;; rpm) build_rpm ;;
appimage) build_appimage ;; appimage) build_appimage ;;
win) build_win ;; win) build_win ;;
mac) build_mac ;;
all) build_deb; build_rpm; build_appimage all) build_deb; build_rpm; build_appimage
if win_available; then build_win; fi ;; if win_available; then build_win; fi
*) err "unknown target '$WANT' (want: deb|rpm|appimage|win|all)"; exit 2 ;; if mac_available; then build_mac; fi ;;
*) err "unknown target '$WANT' (want: deb|rpm|appimage|win|mac|all)"; exit 2 ;;
esac esac
# ── Summary ────────────────────────────────────────────────────────────────── # ── Summary ──────────────────────────────────────────────────────────────────
echo echo
say "package summary" say "package summary"
status=0 status=0
for fmt in deb rpm appimage win; do for fmt in deb rpm appimage win mac; do
[[ -v RESULT[$fmt] ]] || continue [[ -v RESULT[$fmt] ]] || continue
state="${RESULT[$fmt]%% *}" state="${RESULT[$fmt]%% *}"
detail="${RESULT[$fmt]#* }" detail="${RESULT[$fmt]#* }"