mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:37:11 -06:00
The Export dialog gets "Add to Apple Photos" with an optional album (macOS
only). app.export takes addToPhotos / photosAlbum (dialog, Export with
Previous, presets, MCP, lightcraft-cli run and render --opt); the new
export.addToPhotos {paths, album?, wait?} adds files already on disk and
export.photosImports {job?} reports how imports went.
A constant AppleScript run by /usr/bin/osascript asks Photos' import
command (Apple Events, not synthetic input) to import the files into a
top-level album of exactly that name (made when missing; albums in folders
are never used; several of that name: refused), skip check duplicates
false, and returns the media item ids. Album names and paths are passed
only as separate arguments (on run argv), never spliced into the script.
The runner is injectable (Session::apple_photos): with_fs installs the real
one on macOS only, and tests use fakes, so no test starts osascript or
Photos.
Imports are jobs (Session::apple_photos_imports), one at a time. The
desktop app (Session::apple_photos_background, set in open_session) never
waits for Photos on its UI thread: the background export adds on its
worker, app.export without background and export.addToPhotos answer with
the running job, and the app announces the outcome. Without a window (MCP,
lightcraft-cli) imports wait by default, and both headless front ends (the
MCP/CLI backend and the snapshot UI) wait for a running one before the
process ends, on every exit path, through one engine helper
(Session::wait_for_photos_imports) whose deadline follows the job: a
writing allowance while an export still writes, then the import's own
time limit from when Photos was asked, under an overall cap. A runner that panics, waiting or on a
worker, ends its import as failed and frees the slot (panic guard plus a
completion guard). Session::after_export reserves
the import slot before an export writes anything and frees it on every
exit path (handed the files, cancelled, failed, dropped), so a competing
import is refused with the export's job number instead of the export
failing its Photos step after writing. Permission (-1743), missing Photos,
cancel, timeouts, ambiguous albums, busy imports, other osascript failures
and partial imports produce actionable messages; the export's files are
kept either way. Hidden on other platforms; the web build compiles it out.
Packaging: the hardened-runtime entitlements gain
com.apple.security.automation.apple-events (app and CLI) and Info.plist
gains NSAppleEventsUsageDescription, without which macOS refuses the
events without asking.
Not yet tested end to end against a real Photos library: manual steps,
with the packaged app, in docs/apple-photos.md. Parity row LR-EXP-PHOTOS is
now partial.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
7.5 KiB
Bash
Executable File
174 lines
7.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Build, sign and (optionally) notarize the macOS release artifacts:
|
|
#
|
|
# $DIST/lightcraft-<version>-macos-<arch>.dmg LightCraft.app on a drag-to-Applications DMG
|
|
# $DIST/lightcraft-cli-<version>-macos-<arch>.zip the headless CLI
|
|
#
|
|
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
|
|
#
|
|
# Signing (env):
|
|
# MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-" = ad-hoc (local testing;
|
|
# Gatekeeper will reject the result on other Macs). In CI, import-cert.sh sets it.
|
|
# MACOS_KEYCHAIN keychain holding the identity (optional)
|
|
# Notarization (env; all three needed, and a real identity):
|
|
# APPLE_ID, APPLE_PASSWORD (app-specific password), APPLE_TEAM_ID
|
|
set -euo pipefail
|
|
# shellcheck source=../env.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
|
HERE="$ROOT/packaging/macos"
|
|
|
|
ARCH=universal
|
|
SKIP_BUILD=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--arch) ARCH="$2"; shift 2 ;;
|
|
--skip-build) SKIP_BUILD=1; shift ;;
|
|
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
|
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
case "$ARCH" in
|
|
universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;;
|
|
aarch64) TARGETS=(aarch64-apple-darwin) ;;
|
|
x86_64) TARGETS=(x86_64-apple-darwin) ;;
|
|
*) echo "unknown --arch $ARCH" >&2; exit 2 ;;
|
|
esac
|
|
|
|
# Keep in sync with LSMinimumSystemVersion in Info.plist.in.
|
|
export MACOSX_DEPLOYMENT_TARGET=11.0
|
|
IDENTITY="${MACOS_SIGN_IDENTITY:--}"
|
|
SHORT_VERSION="${VERSION%%-*}"
|
|
WORK="$CARGO_TARGET_DIR/macos-package"
|
|
APP="$WORK/LightCraft.app"
|
|
DMG="$DIST/lightcraft-$VERSION-macos-$ARCH.dmg"
|
|
CLI_ZIP="$DIST/lightcraft-cli-$VERSION-macos-$ARCH.zip"
|
|
|
|
NOTARIZE=0
|
|
if [ "$IDENTITY" = "-" ]; then
|
|
warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
|
|
elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
|
|
NOTARIZE=1
|
|
else
|
|
warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
|
|
fi
|
|
|
|
echo "==> LightCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE"
|
|
|
|
# ---- build -------------------------------------------------------------------------------------
|
|
if [ "$SKIP_BUILD" = 0 ]; then
|
|
args=()
|
|
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
|
|
(cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif "${args[@]}")
|
|
fi
|
|
|
|
rm -rf "$WORK"
|
|
mkdir -p "$WORK/bin"
|
|
for bin in lightcraft lightcraft-cli; do
|
|
inputs=()
|
|
for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done
|
|
lipo -create -output "$WORK/bin/$bin" "${inputs[@]}"
|
|
lipo -info "$WORK/bin/$bin"
|
|
done
|
|
|
|
# ---- signing helpers ---------------------------------------------------------------------------
|
|
sign() {
|
|
# Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
|
|
local ts=(--timestamp)
|
|
[ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
|
|
local kc=()
|
|
[ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
|
|
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
|
|
}
|
|
|
|
notarize() {
|
|
local file="$1" out id status
|
|
echo "==> notarizing $(basename "$file") (this can take a few minutes)"
|
|
out="$(xcrun notarytool submit "$file" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
|
--team-id "$APPLE_TEAM_ID" --wait --timeout 1h --output-format json)" || true
|
|
echo "$out"
|
|
id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
|
|
status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
|
|
if [ "$status" != "Accepted" ]; then
|
|
if [ -n "$id" ]; then
|
|
xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
|
|
fi
|
|
echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ---- LightCraft.app ----------------------------------------------------------------------------
|
|
echo "==> assembling $APP"
|
|
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
|
# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile).
|
|
cp "$WORK/bin/lightcraft" "$APP/Contents/MacOS/LightCraft"
|
|
cp "$ROOT/assets/app-icon/lightcraft.icns" "$APP/Contents/Resources/LightCraft.icns"
|
|
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
|
|
-e "s/@BUILD_SHA@/${LIGHTCRAFT_BUILD_SHA:-unknown}/g" \
|
|
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
|
|
plutil -lint "$APP/Contents/Info.plist"
|
|
printf 'APPL????' >"$APP/Contents/PkgInfo"
|
|
# Licences (and the craft-fonts font licences when built with CRAFT_FONTS_DIR) inside the bundle.
|
|
mkdir -p "$APP/Contents/Resources/Licenses"
|
|
copy_docs "$APP/Contents/Resources/Licenses"
|
|
|
|
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
|
|
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
|
|
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/LightCraft"
|
|
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
|
|
codesign --verify --strict --deep --verbose=2 "$APP"
|
|
|
|
if [ "$NOTARIZE" = 1 ]; then
|
|
ditto -c -k --keepParent "$APP" "$WORK/LightCraft-notarize.zip"
|
|
notarize "$WORK/LightCraft-notarize.zip"
|
|
xcrun stapler staple "$APP"
|
|
xcrun stapler validate "$APP"
|
|
spctl --assess --type execute -vvv "$APP"
|
|
fi
|
|
|
|
# ---- DMG ---------------------------------------------------------------------------------------
|
|
echo "==> building $DMG"
|
|
STAGE="$WORK/dmg"
|
|
mkdir -p "$STAGE"
|
|
ditto "$APP" "$STAGE/LightCraft.app"
|
|
ln -s /Applications "$STAGE/Applications"
|
|
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
|
|
mkdir -p "$STAGE/.background"
|
|
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
|
|
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
|
|
rm -f "$DMG" "$WORK/raw.dmg"
|
|
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
|
|
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
|
|
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
|
|
hdiutil makehybrid -hfs -hfs-volume-name "LightCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
|
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
|
rm -f "$WORK/raw.dmg"
|
|
sign "$DMG"
|
|
codesign --verify --strict --verbose=2 "$DMG"
|
|
if [ "$NOTARIZE" = 1 ]; then
|
|
notarize "$DMG"
|
|
xcrun stapler staple "$DMG"
|
|
xcrun stapler validate "$DMG"
|
|
spctl --assess --type open --context context:primary-signature -vvv "$DMG"
|
|
fi
|
|
|
|
# ---- CLI ---------------------------------------------------------------------------------------
|
|
echo "==> building $CLI_ZIP"
|
|
CLI_DIR="$WORK/lightcraft-cli-$VERSION-macos-$ARCH"
|
|
mkdir -p "$CLI_DIR"
|
|
cp "$WORK/bin/lightcraft-cli" "$CLI_DIR/"
|
|
copy_docs "$CLI_DIR"
|
|
# Same entitlements as the app: `--opt addToPhotos=true` / `app.export {addToPhotos}` send Apple
|
|
# Events to Photos through osascript too (macOS asks about the app that started the CLI, usually
|
|
# the terminal).
|
|
sign --options runtime --entitlements "$HERE/entitlements.plist" "$CLI_DIR/lightcraft-cli"
|
|
codesign --verify --strict --verbose=2 "$CLI_DIR/lightcraft-cli"
|
|
rm -f "$CLI_ZIP"
|
|
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
|
|
# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online.
|
|
if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
|
|
|
|
"$WORK/bin/lightcraft-cli" --version
|
|
echo "==> done"
|
|
ls -lh "$DMG" "$CLI_ZIP"
|