diff --git a/ROADMAP.md b/ROADMAP.md index dfbcdbc..1a6c980 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,13 +154,19 @@ Linux; `disk_space` (`statvfs`), `plex` (symlinks, with a Windows `symlink_file` path), the mpv IPC `UnixStream`, and the `chmod`/`PermissionsExt` guards are all `cfg(unix)`-gated. -**macOS is deferred, not blocked.** The code is Unix and cfg-gated, so it -*should* compile on a Mac unchanged, but it can't be linked from the -Linux host (no osxcross/SDK) and Codeberg has no macOS runners — so a real -tested `.app`/binary needs either a GitHub Actions `macos-latest` runner -(mirror CI there) or a local osxcross setup. Remaining for either OS: a -real per-OS tray backend (e.g. `tray-icon`) if the tray is wanted off -Linux, and on-hardware runtime testing. +**macOS has a packaging path, pending the toolchain.** `scripts/package.sh +mac` cross-compiles an Apple target (`MAC_ARCH=arm64` default, or +`x86_64`) via osxcross, assembles an unsigned `yt-offline.app` +(`Info.plist` + Mach-O + best-effort `.icns`), and zips it. The crypto +stack is `ring`, which builds against the osxcross SDK fine. It's gated on +the operator having osxcross built with a macOS SDK on PATH (the SDK comes +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 diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 19e20d7..a899702 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -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 reusing a stale cached clone. -## Windows — experimental +## Windows (.zip) -Windows is **not** currently a first-class target. Two Linux-only -dependencies block a clean `--target x86_64-pc-windows-gnu` build: +Windows cross-compiles cleanly from Linux — the formerly-blocking +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 - D-Bus spec, which doesn't exist on Windows. Needs replacing with - `tray-icon` behind `#[cfg(windows)]`, or stubbing `src/tray.rs` to a - no-op on non-Unix. -- **`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. +```sh +rustup target add x86_64-pc-windows-gnu +sudo apt install mingw-w64 zip # or your distro's equivalent +scripts/package.sh win +``` -Once those are addressed, the rest (eframe, axum, rusqlite-bundled) is -already cross-platform — `bundled_ytdlp_path()` and friends already have -`cfg!(windows)` branches. The path to a Windows `.exe`/`.msi` (via -[`cargo-wix`](https://github.com/volks73/cargo-wix)) is then mechanical. +This produces `dist/yt-offline--x86_64-windows.zip` containing +`yt-offline.exe`, `LICENSE.txt`, and a `README.txt` listing the runtime +PATH deps (yt-dlp / ffmpeg / mpv). Release builds link as a GUI-subsystem +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 -on macOS otherwise. A `.app` bundle + `.dmg` would follow once the tray -is abstracted behind a trait with per-OS implementations. +macOS cross-compiles via [osxcross](https://github.com/tpoechtrager/osxcross), +which needs Apple's macOS SDK (extracted from Xcode — its license does not +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---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 `.forgejo/workflows/release.yml` runs `scripts/package.sh all` on every -pushed tag (`v*`) and attaches the resulting `.deb`/`.rpm`/`.AppImage` to -the Codeberg release. See that file for the runner setup. +pushed tag (`v*`) and attaches the resulting artifacts to the Codeberg +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. diff --git a/scripts/package.sh b/scripts/package.sh index 5c193dc..50e47bc 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -2,7 +2,7 @@ # Build distributable packages for yt-offline. # # 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. # 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) # - AppImage → appimagetool (downloaded to dist/tools/ if missing) # - 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 -# formats; the Windows .zip cross-compiles a separate target on demand. -# `all` skips the Windows zip unless the cross toolchain is present, so a -# plain `all` on a stock Linux box still succeeds. +# formats; the Windows/macOS .zips cross-compile separate targets on demand. +# `all` skips the Windows and macOS zips unless their cross toolchains are +# 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 @@ -221,10 +230,139 @@ win_available() { && 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__LINKER (triple + # upper-cased, '-'→'_'); cc-rs (rusqlite-bundled, ring) reads CC/CXX/AR_. + 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" < + + + + CFBundleName yt-offline + CFBundleDisplayName yt-offline + CFBundleIdentifier org.codeberg.yt-offline + CFBundleVersion ${VERSION} + CFBundleShortVersionString ${VERSION} + CFBundleExecutable ${PKG} + CFBundleIconFile ${PKG}.icns + CFBundlePackageType APPL + LSMinimumSystemVersion 11.0 + NSHighResolutionCapable + + +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 ───────────────────────────────────────────────────────────────── -# The Windows zip doesn't need the Linux release binary, so skip that slow -# build when Windows is all that was asked for. -if [[ "$WANT" != "win" ]]; then +# The Windows/macOS zips don't need the Linux release binary, so skip that +# slow build when a cross target is all that was asked for. +if [[ "$WANT" != "win" && "$WANT" != "mac" ]]; then build_binary fi @@ -233,16 +371,18 @@ case "$WANT" in rpm) build_rpm ;; appimage) build_appimage ;; win) build_win ;; + mac) build_mac ;; all) build_deb; build_rpm; build_appimage - if win_available; then build_win; fi ;; - *) err "unknown target '$WANT' (want: deb|rpm|appimage|win|all)"; exit 2 ;; + if win_available; then build_win; fi + if mac_available; then build_mac; fi ;; + *) err "unknown target '$WANT' (want: deb|rpm|appimage|win|mac|all)"; exit 2 ;; esac # ── Summary ────────────────────────────────────────────────────────────────── echo say "package summary" status=0 -for fmt in deb rpm appimage win; do +for fmt in deb rpm appimage win mac; do [[ -v RESULT[$fmt] ]] || continue state="${RESULT[$fmt]%% *}" detail="${RESULT[$fmt]#* }"