diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3051d9ba..6efe04df 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -76,7 +76,7 @@ jobs: - name: Build release binary env: - MACOSX_DEPLOYMENT_TARGET: ${{ runner.os == 'macOS' && '12.0' || '' }} + MACOSX_DEPLOYMENT_TARGET: ${{ runner.os == 'macOS' && '13.0' || '' }} run: cargo build --release --locked --target "${{ matrix.target }}" --bin shosai - name: Download pinned PDFium diff --git a/Makefile b/Makefile index 70aac8e1..34e1a4c0 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,8 @@ test: test-scripts: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover \ -s benchmarks/epub-page-turn/2026-08-17/tests + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover \ + -s scripts/tests ## Regenerate CHANGELOG.md from conventional commits changelog: diff --git a/docs/adr/001-epub-renderer.md b/docs/adr/001-epub-renderer.md index d599ffb6..c256631c 100644 --- a/docs/adr/001-epub-renderer.md +++ b/docs/adr/001-epub-renderer.md @@ -312,7 +312,7 @@ Those behaviors need explicit tests rather than visual inference. | Target | Child-view path | Current finding | |---------------|--------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| -| macOS 12+ | Iced raw AppKit handle → child `WKWebView` | Builds and renders with the system WebKit; no extra runtime packaged | +| macOS 13+ | Iced raw AppKit handle → child `WKWebView` | Builds and renders with the system WebKit; no extra runtime packaged | | Windows CI | Iced raw Win32 handle → child WebView2 | API path exists; runtime, accessibility, focus, and CI spike not yet tested | | Linux X11 | Iced raw Xlib handle → WebKitGTK child | Builds on x86_64; automated resize/teardown and zero-network proofs pass under Xvfb; interactive and arm64 evidence remain open | | Linux Wayland | GTK container via Wry Unix extension | Raw child embedding is unsupported; standard Iced runner exposes no GTK container, making this the main Wry portability blocker | diff --git a/docs/plans/001-enhanced-epub-rendering.org b/docs/plans/001-enhanced-epub-rendering.org index 3424bead..7593d171 100644 --- a/docs/plans/001-enhanced-epub-rendering.org +++ b/docs/plans/001-enhanced-epub-rendering.org @@ -451,9 +451,17 @@ Working evidence: [[file:../adr/001-epub-renderer.md][ADR 001: EPUB renderer]]. ~app/epub_navigation.rs~. Image presentation caching plus paginated and continuous EPUB widget construction now live in ~app/epub_view.rs~; generic reader chrome and state/update ownership remain in ~app.rs~. -- [ ] Add feature/build configuration and packaging changes only for the native - implementation; document runtime prerequisites and attribution. -- [ ] Run the full workspace test suite and platform packaging smoke tests. +- [X] Add feature/build configuration and packaging changes only for the native + implementation; document runtime prerequisites and attribution. Release + packages bundle PDFium and Inter with their licenses. macOS release builds + use the host Apple compiler and SDK with a macOS 13 deployment target, and + package validation rejects newer or non-portable Mach-O load commands. +- [X] Run the full workspace test suite and platform packaging smoke tests. + The final M5 stack passes 421 Rust tests and 10 script tests on native + x86_64 Linux, plus release build, package validation, and extracted-app + runtime smoke tests on Linux and Apple Silicon macOS. macOS inspection + confirms the app and bundled PDFium both target macOS 13 and contain no + non-system absolute load dependencies. * Tests and Fixtures diff --git a/docs/plans/005-distribution-packaging.org b/docs/plans/005-distribution-packaging.org index 8ed0cef7..dad8d336 100644 --- a/docs/plans/005-distribution-packaging.org +++ b/docs/plans/005-distribution-packaging.org @@ -322,7 +322,7 @@ Verify all downloaded installer tools and PDFium inputs by digest. - Apple Developer Program membership, a Developer ID Application certificate, and an App Store Connect issuer/key ID/API key (or approved notarytool profile). - Confirm hardened-runtime entitlements by testing both architectures, PDFium, - file dialogs, and GPU rendering. Keep ~LSMinimumSystemVersion=12.0~ unless + file dialogs, and GPU rendering. Keep ~LSMinimumSystemVersion=13.0~ unless compatibility testing changes it. ** Actionable changes and likely files diff --git a/docs/releasing.md b/docs/releasing.md index ebfb2eae..0534cf11 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -35,7 +35,9 @@ Linux packages contain an optimized binary, PDFium, licenses, a desktop entry, a The macOS zip contains `Shosai.app`. Extract it and move the application into `/Applications` or `~/Applications`. The bundle is ad-hoc signed but is not yet Developer ID signed or notarized, so macOS may show a Gatekeeper warning for downloaded releases. -PDFium is pinned to the checksummed `chromium/7999` binaries from `bblanchon/pdfium-binaries`. Shosai resolves the bundled library relative to its executable and falls back to the system library for development builds. +macOS release builds require the Apple Command Line Tools and target macOS 13, matching the bundled PDFium binary. The Nix development shell selects the host Apple compiler and SDK for Cargo release builds so packaged binaries link only to system or bundle-relative libraries. Package validation rejects Mach-O files whose deployment target exceeds the bundle's declared minimum or whose load commands contain non-portable absolute dependencies. + +PDFium is pinned to the checksummed `chromium/7999` binaries from `bblanchon/pdfium-binaries`. Shosai resolves the bundled library relative to its executable and falls back to the system library for development builds. Release packages include the project, PDFium, and Inter font licenses alongside the corresponding runtime assets. ## Required repository settings diff --git a/flake.nix b/flake.nix index 8d7b40af..c45b8d41 100644 --- a/flake.nix +++ b/flake.nix @@ -250,6 +250,17 @@ // (pkgs.lib.optionalAttrs pkgs.stdenv.isDarwin { # macOS: DYLD_LIBRARY_PATH for dynamic libraries DYLD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.pdfium-binaries ]; + + # Release artifacts must use the host Apple SDK and system libraries, + # rather than embedding dependencies from the Nix SDK or store. + CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER = "/usr/bin/clang"; + CC_aarch64_apple_darwin = "/usr/bin/clang"; + CXX_aarch64_apple_darwin = "/usr/bin/clang++"; + shellHook = '' + export DEVELOPER_DIR=/Library/Developer/CommandLineTools + export SDKROOT="$DEVELOPER_DIR/SDKs/MacOSX.sdk" + export MACOSX_DEPLOYMENT_TARGET=13.0 + ''; }) ); } diff --git a/scripts/check-macos-package.sh b/scripts/check-macos-package.sh index 2e87d0be..7b0789dd 100755 --- a/scripts/check-macos-package.sh +++ b/scripts/check-macos-package.sh @@ -9,6 +9,7 @@ fi version=${1#v} architecture=$2 archive=$3 +plist_buddy=${PLIST_BUDDY:-/usr/libexec/PlistBuddy} case "$architecture" in x86_64) binary_architecture="x86_64" ;; @@ -59,14 +60,131 @@ test "$(lipo -archs "$binary")" = "$binary_architecture" test "$(lipo -archs "$pdfium")" = "$binary_architecture" plutil -lint "$info_plist" -test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info_plist")" = \ +test "$("$plist_buddy" -c 'Print :CFBundleIdentifier' "$info_plist")" = \ "io.github.chaba2.shosai" -test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$info_plist")" = "Shosai" -test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$info_plist")" = "Shosai" -test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$info_plist")" = \ +test "$("$plist_buddy" -c 'Print :CFBundleExecutable' "$info_plist")" = "Shosai" +test "$("$plist_buddy" -c 'Print :CFBundleIconFile' "$info_plist")" = "Shosai" +test "$("$plist_buddy" -c 'Print :CFBundleShortVersionString' "$info_plist")" = \ "$version" -test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$info_plist")" = "$version" -test "$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$info_plist")" = "12.0" +test "$("$plist_buddy" -c 'Print :CFBundleVersion' "$info_plist")" = "$version" +minimum_system_version=$("$plist_buddy" -c 'Print :LSMinimumSystemVersion' "$info_plist") +test "$minimum_system_version" = "13.0" + +macho_build_target() { + awk ' + $1 == "cmd" { command = $2; platform = ""; next } + command == "LC_BUILD_VERSION" && $1 == "platform" { platform = $2; next } + command == "LC_BUILD_VERSION" && $1 == "minos" { print platform, $2; exit } + command == "LC_VERSION_MIN_MACOSX" && $1 == "version" { print "MACOS", $2; exit } + ' +} + +version_is_greater() { + awk -v actual="$1" -v declared="$2" 'BEGIN { + split(actual, left, "."); split(declared, right, ".") + for (part = 1; part <= 4; part++) { + if ((left[part] + 0) > (right[part] + 0)) exit 0 + if ((left[part] + 0) < (right[part] + 0)) exit 1 + } + exit 1 + }' +} + +path_has_dot_component() { + case "/$1/" in + */./*|*/../*) return 0 ;; + *) return 1 ;; + esac +} + +check_macho_portability() { + local label=$1 + local macho=$2 + local load_commands + if ! load_commands=$(otool -l "$macho"); then + echo "cannot inspect Mach-O load commands for $label" >&2 + exit 1 + fi + + local target + local platform + local minimum + target=$(printf '%s\n' "$load_commands" | macho_build_target) + read -r platform minimum <<< "$target" + if [[ -z "$minimum" ]]; then + echo "cannot determine minimum macOS version for $label" >&2 + exit 1 + fi + case "$platform" in + 1|MACOS) ;; + *) + echo "$label is not a macOS Mach-O (platform $platform)" >&2 + exit 1 + ;; + esac + if version_is_greater "$minimum" "$minimum_system_version"; then + echo "$label requires macOS $minimum but package declares $minimum_system_version" >&2 + exit 1 + fi + + local rpaths + rpaths=$(printf '%s\n' "$load_commands" | awk ' + $1 == "cmd" { in_rpath = ($2 == "LC_RPATH"); next } + in_rpath && $1 == "path" { + line = $0 + sub(/^[[:space:]]*path[[:space:]]+/, "", line) + sub(/[[:space:]]+\(offset [0-9]+\)$/, "", line) + print line + in_rpath = 0 + } + ') + while IFS= read -r rpath; do + [[ -z "$rpath" ]] && continue + if path_has_dot_component "$rpath"; then + echo "path traversal in LC_RPATH for $label: $rpath" >&2 + exit 1 + fi + case "$rpath" in + /usr/lib|/usr/lib/*|/System/Library|/System/Library/*|@loader_path|@loader_path/*|@executable_path|@executable_path/*) ;; + *) + echo "non-portable LC_RPATH in $label: $rpath" >&2 + exit 1 + ;; + esac + done <<< "$rpaths" + + local dependency_output + if ! dependency_output=$(otool -L "$macho"); then + echo "cannot inspect Mach-O dependencies for $label" >&2 + exit 1 + fi + local dependencies + dependencies=$(printf '%s\n' "$dependency_output" | sed -n '2,$ { + s/^[[:space:]]*// + s/ (compatibility version.*$// + p + }') + while IFS= read -r dependency; do + # otool reports a dylib's install name before its actual dependencies. + if [[ "$label" == "PDFium library" && "$dependency" == "./libpdfium.dylib" ]]; then + continue + fi + if path_has_dot_component "$dependency"; then + echo "path traversal in Mach-O dependency for $label: $dependency" >&2 + exit 1 + fi + case "$dependency" in + ""|/usr/lib/*|/System/Library/*|@rpath/*|@loader_path/*|@executable_path/*) ;; + *) + echo "non-portable Mach-O dependency in $label: $dependency" >&2 + exit 1 + ;; + esac + done <<< "$dependencies" +} + +check_macho_portability "Shosai binary" "$binary" +check_macho_portability "PDFium library" "$pdfium" codesign --verify --deep --strict --verbose=2 "$app" diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 3740b27a..f1cd12bf 100755 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -49,7 +49,7 @@ cat > "$app/Contents/Info.plist" <CFBundlePackageTypeAPPL CFBundleShortVersionString$version CFBundleVersion$version - LSMinimumSystemVersion12.0 + LSMinimumSystemVersion13.0 NSHighResolutionCapable diff --git a/scripts/tests/test_check_macos_package.py b/scripts/tests/test_check_macos_package.py new file mode 100644 index 00000000..2585e1b0 --- /dev/null +++ b/scripts/tests/test_check_macos_package.py @@ -0,0 +1,215 @@ +import os +from pathlib import Path +import stat +import subprocess +import tempfile +import unittest +import zipfile + + +REPOSITORY = Path(__file__).resolve().parents[2] +CHECKER = REPOSITORY / "scripts" / "check-macos-package.sh" + + +class MacosPackageCheckerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.archive = self.root / "Shosai-1.0.0-macos-aarch64.zip" + self._write_archive() + self._write_tool("lipo", "printf 'arm64\\n'") + self._write_tool("plutil", ":") + self._write_tool("codesign", ":") + self._write_tool( + "PlistBuddy", + """ +case "$2" in + *CFBundleIdentifier*) printf 'io.github.chaba2.shosai\\n' ;; + *CFBundleExecutable*|*CFBundleIconFile*) printf 'Shosai\\n' ;; + *CFBundleShortVersionString*|*CFBundleVersion*) printf '1.0.0\\n' ;; + *LSMinimumSystemVersion*) printf '%s\\n' "${FAKE_DECLARED_MINIMUM:-13.0}" ;; + *) exit 1 ;; +esac +""", + ) + self._write_tool( + "ditto", + 'python3 - "$3" "$4" <<\'PY\'\n' + "import os, pathlib, sys, zipfile\n" + "with zipfile.ZipFile(sys.argv[1]) as archive:\n" + " archive.extractall(sys.argv[2])\n" + "root = pathlib.Path(sys.argv[2]) / 'Shosai.app/Contents'\n" + "for path in [root / 'MacOS/Shosai', root / 'Frameworks/libpdfium.dylib']:\n" + " path.chmod(path.stat().st_mode | 0o111)\n" + "PY", + ) + self._write_tool( + "otool", + """ +if [[ $1 == -l ]]; then + case "$2" in + */libpdfium.dylib) minimum=${FAKE_PDFIUM_MINOS:-13.0} ;; + *) minimum=${FAKE_BINARY_MINOS:-13.0} ;; + esac + cat <