feat(apple): add direct-download macOS channel (notarized DMG + Sparkle + Homebrew)

Add a second macOS shipping channel alongside the App Store build:

- New VniDropDirect target (Release-Direct config) sharing VniDrop's sources via
  an AppBase target template; links Sparkle behind the DIRECT_DISTRIBUTION flag so
  the App Store binary never bundles a self-updater. arm64-only (core is arm64).
- Sparkle updater (SparkleUpdater.swift) + "Check for Updates" menu, compiled only
  under DIRECT_DISTRIBUTION; Info.plist SUFeedURL points at the GitHub Release
  /latest/download/appcast.xml, non-sandboxed entitlements for Developer ID.
- build-dmg.sh (archive → Developer ID export → DMG → sign → notarize → staple),
  generate-appcast.sh, and ExportOptions-DeveloperID.plist.
- apple-release.yml: on tag v*.*.*, build/notarize the DMG, publish the GitHub
  Release with appcast, and push the Homebrew cask to sudosylabs/homebrew-vnidrop.
  apple.yml gains a PR compile-check of the direct target.
- CFBundleVersion is stamped at build time as a UTC YYMMDD.HHMM timestamp for both
  channels, replacing the hand-maintained build number.
- Docs (RELEASE-MACOS.md, README), cask template + tap README, localized
  updates_check string, Makefile targets, gitignore for dist/ artifacts.
This commit is contained in:
2026-07-27 14:31:36 +02:00
parent 8de190a36e
commit cc194f6a7b
17 changed files with 950 additions and 9 deletions

View File

@@ -0,0 +1,19 @@
<?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">
<!-- Export options for the direct-download (Developer ID) macOS build consumed by
`xcodebuild -exportArchive` in build-dmg.sh. This produces a Developer
IDsigned, hardened-runtime .app suitable for notarization and distribution
outside the Mac App Store. The App Store build uses a different flow entirely. -->
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>manual</string>
<!-- Xcode manages the Developer ID Application certificate lookup from the
keychain; the hardened runtime is enabled via ENABLE_HARDENED_RUNTIME in the
VniDropDirect target. -->
<key>teamID</key>
<string>${DEVELOPMENT_TEAM}</string>
</dict>
</plist>

158
apple/scripts/build-dmg.sh Executable file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bash
#
# Builds the direct-download macOS artifact: a Developer IDsigned, notarized
# .dmg of the VniDropDirect target (the Sparkle-enabled build). Produces:
# - apple/dist/VniDrop-<version>.dmg (signed + stapled when notarizing)
#
# This is the direct-distribution counterpart to the App Store archive flow; it
# never touches the App Store `VniDrop` target. The Rust crate is not modified.
#
# Usage: apple/scripts/build-dmg.sh [version]
# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag.
#
# Environment:
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
# Auto-detected from the keychain when unset.
# DEVELOPMENT_TEAM Apple team ID (10 chars). Auto-derived from the identity.
# NOTARY_PROFILE Name of a `xcrun notarytool store-credentials` keychain
# profile. When set, the DMG is notarized and stapled; when
# unset the build still produces a signed DMG and prints the
# pending notarization step (useful before creds exist).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
DIST_DIR="$APPLE_DIR/dist"
BUILD_DIR="$APPLE_DIR/.build-dmg"
PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
SCHEME="VniDropDirect"
CONFIG="Release-Direct"
APP_NAME="VniDrop"
# --- Resolve version (arg > git tag > project MARKETING_VERSION) -------------
resolve_version() {
local v="${1:-}"
if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
v="${GITHUB_REF_NAME#v}"
fi
if [ -z "$v" ]; then
v="$(sed -nE 's/.*MARKETING_VERSION: "([0-9.]+)".*/\1/p' "$APPLE_DIR/project.yml" | head -1)"
fi
if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "version must be MAJOR.MINOR.PATCH (got '$v')" >&2
exit 1
fi
printf '%s' "$v"
}
VERSION="$(resolve_version "${1:-}")"
# CFBundleVersion is a UTC YYMMDD.HHMM timestamp stamped by the target's
# "Stamp build number" build phase. Pin it here (one value for the whole archive)
# so the app, DMG, and appcast all agree; Sparkle compares it to order updates.
BUILD_NUMBER="$(date -u +%y%m%d.%H%M)"
export VNIDROP_BUILD="$BUILD_NUMBER"
# --- Resolve signing identity ------------------------------------------------
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
DEVELOPER_ID_APP="$(security find-identity -v -p codesigning 2>/dev/null \
| sed -nE 's/.*"(Developer ID Application: [^"]+)".*/\1/p' | head -1)"
fi
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
echo "error: no 'Developer ID Application' identity found in the keychain." >&2
echo " Create one in Xcode ▸ Settings ▸ Accounts, or set DEVELOPER_ID_APP." >&2
exit 1
fi
if [ -z "${DEVELOPMENT_TEAM:-}" ]; then
# The team ID is the 10-char code in the trailing parenthesis of the identity.
DEVELOPMENT_TEAM="$(printf '%s' "$DEVELOPER_ID_APP" | sed -nE 's/.*\(([A-Z0-9]{10})\)$/\1/p')"
fi
echo "==> Direct build v$VERSION (CFBundleVersion $BUILD_NUMBER)"
echo " identity: $DEVELOPER_ID_APP"
echo " team: ${DEVELOPMENT_TEAM:-<unknown>}"
# --- Build core + regenerate project ----------------------------------------
# Release core needs LTO disabled (workspace thin-LTO miscompiles proc-macros).
echo "==> Building Rust core (release)"
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
echo "==> Regenerating Xcode project"
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
ARCHIVE="$BUILD_DIR/$APP_NAME.xcarchive"
EXPORT_DIR="$BUILD_DIR/export"
# --- Archive + export (Developer ID) ----------------------------------------
echo "==> Archiving $SCHEME ($CONFIG)"
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIG" \
-destination 'generic/platform=macOS' \
-archivePath "$ARCHIVE" \
MARKETING_VERSION="$VERSION" \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="$DEVELOPER_ID_APP" \
| xcbeautify 2>/dev/null || true
[ -d "$ARCHIVE" ] || { echo "error: archive failed" >&2; exit 1; }
echo "==> Exporting Developer ID app"
EXPORT_OPTS="$BUILD_DIR/ExportOptions.plist"
sed "s/\${DEVELOPMENT_TEAM}/$DEVELOPMENT_TEAM/" \
"$SCRIPT_DIR/ExportOptions-DeveloperID.plist" > "$EXPORT_OPTS"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTS"
APP="$EXPORT_DIR/$APP_NAME.app"
[ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; }
# --- Build the DMG -----------------------------------------------------------
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
rm -f "$DMG"
STAGING="$BUILD_DIR/dmg-staging"
rm -rf "$STAGING" && mkdir -p "$STAGING"
cp -R "$APP" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
echo "==> Building DMG"
if command -v create-dmg >/dev/null 2>&1; then
create-dmg \
--volname "$APP_NAME" \
--app-drop-link 380 205 \
--icon "$APP_NAME.app" 130 205 \
--window-size 540 380 \
--no-internet-enable \
"$DMG" "$STAGING" >/dev/null || {
# create-dmg exits non-zero if it can't set the fancy layout; fall back.
[ -f "$DMG" ] || hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
-ov -format UDZO "$DMG" >/dev/null
}
else
hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
-ov -format UDZO "$DMG" >/dev/null
fi
echo "==> Signing DMG"
codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
# --- Notarize + staple -------------------------------------------------------
if [ -n "${NOTARY_PROFILE:-}" ]; then
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
echo "==> Stapling"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl -a -vvv --type install "$DMG" || true
else
echo "==> NOTARY_PROFILE unset — skipping notarization."
echo " The DMG is signed but NOT notarized; Gatekeeper will block it until"
echo " you run 'xcrun notarytool store-credentials' and re-run with NOTARY_PROFILE set."
fi
SIZE="$(stat -f%z "$DMG")"
echo "==> Done."
echo " dmg: $DMG"
echo " version: $VERSION"
echo " size: $SIZE bytes"

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# Generates/updates the Sparkle appcast for the direct-download build. Runs
# Sparkle's `generate_appcast` over the DMGs in apple/dist/, writing:
# - apple/dist/appcast.xml
#
# The <enclosure> URLs point at the matching GitHub Release download assets, and
# each item is signed with the project's EdDSA key (from a key file or the
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
#
# Usage: apple/scripts/generate-appcast.sh [version]
#
# Environment:
# DIST_DIR Folder holding the DMG(s). Default: apple/dist
# SPARKLE_BIN Dir containing generate_appcast. Auto-located when unset.
# SPARKLE_ED_KEY_FILE Path to the EdDSA private key file. When unset,
# generate_appcast reads the key from the login keychain.
# RELEASE_REPO owner/repo for enclosure URLs. Default: sudosylabs/vnidrop
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
VERSION="${1:-}"
if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
fi
[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; }
# Enclosure URLs resolve to the specific release's assets.
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"
# --- Locate generate_appcast -------------------------------------------------
find_tool() {
local name="$1"
if [ -n "${SPARKLE_BIN:-}" ] && [ -x "$SPARKLE_BIN/$name" ]; then
printf '%s' "$SPARKLE_BIN/$name"; return 0
fi
if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return 0; fi
# Sparkle SPM artifact bundle lands under DerivedData SourcePackages.
local dd="${APPLE_DERIVED_DATA:-$HOME/Library/Developer/Xcode/DerivedData}"
local hit
hit="$(find "$dd" "$HOME/Library/Caches/org.swift.swiftpm" -type f -name "$name" \
-perm -111 2>/dev/null | head -1 || true)"
[ -n "$hit" ] && { printf '%s' "$hit"; return 0; }
return 1
}
GENERATE_APPCAST="$(find_tool generate_appcast || true)"
if [ -z "$GENERATE_APPCAST" ]; then
echo "error: generate_appcast not found. Set SPARKLE_BIN to Sparkle's bin/ dir" >&2
echo " (download from https://github.com/sparkle-project/Sparkle/releases)." >&2
exit 1
fi
echo "==> Using $GENERATE_APPCAST"
# --- Generate ----------------------------------------------------------------
args=( --download-url-prefix "$DOWNLOAD_PREFIX/" -o "$DIST_DIR/appcast.xml" )
if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then
args+=( --ed-key-file "$SPARKLE_ED_KEY_FILE" )
fi
echo "==> Generating appcast (v$VERSION) → $DIST_DIR/appcast.xml"
"$GENERATE_APPCAST" "${args[@]}" "$DIST_DIR"
echo "==> Done. Enclosure prefix: $DOWNLOAD_PREFIX/"