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

4
apple/.gitignore vendored
View File

@@ -18,3 +18,7 @@ Local.xcconfig
.swiftpm/
DerivedData/
*.xcuserstate
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
.build-dmg/
dist/

View File

@@ -34,12 +34,30 @@ Prerequisites: Xcode, Rust with the Apple targets
make apple-core # Rust core, Swift bindings, and XCFramework
make apple-project # generate apple/VniDrop.xcodeproj
make open-apple-project # generate and open the project in Xcode
make build-apple-macos # unsigned macOS build
make build-apple-macos # unsigned macOS build (App Store target)
make open-apple # build and launch the macOS app
make build-apple-ios # unsigned iOS simulator build
make build-apple-ios # unsigned iOS simulator app
make check-apple # iOS simulator tests
```
### macOS shipping channels
The macOS app ships through two targets that build identical sources:
- **`VniDrop`** (`Release`) — Mac App Store / TestFlight. Sandboxed, no
self-updater.
- **`VniDropDirect`** (`Release-Direct`) — direct-download `.dmg` on GitHub
Releases + Homebrew cask. Adds the **Sparkle** auto-updater behind the
`DIRECT_DISTRIBUTION` compile flag, so the App Store binary never links Sparkle.
```bash
make build-apple-macos-direct # unsigned compile-check of the direct target
make build-apple-dmg VERSION=x.y.z # signed (+ notarized) .dmg
```
Full signing, notarization, appcast, and cask flow: see
[`RELEASE-MACOS.md`](RELEASE-MACOS.md).
Use `APPLE_PROFILE=release` to request a release Rust core, or set
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
Code signing is disabled for the app and test targets; local and CI builds do

155
apple/RELEASE-MACOS.md Normal file
View File

@@ -0,0 +1,155 @@
# Releasing VniDrop for macOS
VniDrop ships on macOS through **two independent channels**:
| Channel | Target / config | Signing | Updates |
| --- | --- | --- | --- |
| **Mac App Store / TestFlight** | `VniDrop` / `Release` | Apple Distribution | App Store |
| **Direct download (.dmg)** | `VniDropDirect` / `Release-Direct` | Developer ID + notarization | Sparkle |
The direct build adds the **Sparkle** auto-updater, gated behind the
`DIRECT_DISTRIBUTION` compile flag so the App Store binary never links or ships a
self-updater (which Apple forbids). Because Swift Package Manager links products
per *target*, the isolation is a dedicated `VniDropDirect` target — not just a
build configuration.
This document covers the **direct-download** channel. The App Store channel goes
through Xcode Organizer / App Store Connect as before (see
`RELEASE-TESTFLIGHT.fr.md`).
## Versioning
Both channels use Apple's two-field convention, shown as `MARKETING_VERSION (build)`:
- **`CFBundleShortVersionString`** = `MARKETING_VERSION` — the human `X.Y.Z` version
(from the git tag for a release). This is what users, the DMG filename, the
GitHub Release, and the Homebrew cask all use.
- **`CFBundleVersion`** = a **UTC `YYMMDD.HHMM` timestamp**, stamped into the built
Info.plist by the "Stamp build number" build phase (`apple/project.yml`). It fires
for every build — Xcode Organizer archive and CLI alike — so each build is
monotonic and self-describing. Sparkle compares this field to order updates, and
App Store Connect requires each upload's build to exceed the previous one; a
timestamp satisfies both automatically. `build-dmg.sh` pins one timestamp per
archive (via `VNIDROP_BUILD`) so the app, DMG, and appcast agree.
No build number is maintained by hand.
---
## One-time setup
### 1. Developer ID Application certificate
Direct distribution requires a **Developer ID Application** certificate (distinct
from the *Apple Distribution* cert used for the App Store). In Xcode ▸ Settings ▸
Accounts ▸ Manage Certificates ▸ **+** ▸ *Developer ID Application*. Confirm it's
in the keychain:
```bash
security find-identity -v -p codesigning | grep "Developer ID Application"
```
### 2. Notarization credentials (App Store Connect API key)
Create an API key at App Store Connect ▸ Users and Access ▸ Integrations ▸ Keys
(role: *Developer*). Download the `AuthKey_XXXX.p8`, and note the **Key ID** and
**Issuer ID**. Store a local notarytool profile:
```bash
xcrun notarytool store-credentials vnidrop-notary \
--key /path/to/AuthKey_XXXX.p8 \
--key-id <KEY_ID> \
--issuer <ISSUER_ID>
```
### 3. Sparkle EdDSA signing key
Generate the update-signing key once (private key stored in the login keychain):
```bash
# Sparkle's generate_keys, from the downloaded Sparkle release (bin/generate_keys)
./bin/generate_keys
```
It prints the **public** key. Put it in `apple/VniDrop/Resources/Info.plist` as
`SUPublicEDKey`, replacing `REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY`. Export the
**private** key for CI:
```bash
./bin/generate_keys -x sparkle_ed_private_key # writes the private key file
```
### 4. Homebrew tap repo
Create an empty GitHub repo **`sudosylabs/homebrew-vnidrop`** (a `Casks/` folder
is enough). The release workflow pushes `Casks/vnidrop.rb` there. Users install:
```bash
brew install --cask sudosylabs/vnidrop/vnidrop
```
### 5. CI secrets
Add these to the `sudosylabs/vnidrop` repo (Settings ▸ Secrets ▸ Actions):
| Secret | Value |
| --- | --- |
| `DEVELOPER_ID_CERT_P12` | base64 of the exported Developer ID `.p12` |
| `DEVELOPER_ID_CERT_PASSWORD` | password for that `.p12` |
| `NOTARY_API_KEY` | base64 of `AuthKey_XXXX.p8` |
| `NOTARY_KEY_ID` | App Store Connect key ID |
| `NOTARY_ISSUER` | App Store Connect issuer ID |
| `SPARKLE_ED_PRIVATE_KEY` | contents of the exported Sparkle private key file |
| `HOMEBREW_TAP_TOKEN` | PAT with write access to `homebrew-vnidrop` |
> `SUFeedURL` in Info.plist points at
> `https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml`.
> GitHub's `/releases/latest/download/` path always redirects to the newest
> non-prerelease release's asset, so no GitHub Pages or repo commits are needed.
---
## Per-release flow
1. Bump `MARKETING_VERSION` (and `CURRENT_PROJECT_VERSION`) in `apple/project.yml`
if needed, commit to `master`.
2. Tag and push:
```bash
git tag v0.2.0
git push origin v0.2.0
```
3. `.github/workflows/apple-release.yml` then:
- builds the Rust core (release), regenerates the project,
- archives + exports the `VniDropDirect` target (Developer ID, hardened runtime),
- builds, signs, **notarizes and staples** `VniDrop-<version>.dmg`,
- runs `generate_appcast` to produce `appcast.xml` (enclosure → the release DMG),
- creates the GitHub Release with both assets, and
- renders + pushes the Homebrew cask to the tap.
Existing installs pick up the new version automatically via Sparkle (feed →
latest release's `appcast.xml`); `brew upgrade` respects `auto_updates true`.
---
## Building a DMG locally
```bash
# Signed DMG (skips notarization unless NOTARY_PROFILE is set):
make build-apple-dmg VERSION=0.2.0
# Full signed + notarized DMG:
NOTARY_PROFILE=vnidrop-notary make build-apple-dmg VERSION=0.2.0
```
Output: `apple/dist/VniDrop-<version>.dmg`. See `apple/scripts/build-dmg.sh` for
the environment variables (`DEVELOPER_ID_APP`, `DEVELOPMENT_TEAM`,
`NOTARY_PROFILE`). Generate the appcast with
`apple/scripts/generate-appcast.sh <version>`.
To compile-check the direct target without signing:
```bash
make build-apple-macos-direct
```

View File

@@ -8,6 +8,10 @@ private let mainWindowId = "main"
@main
struct VniDropApp: App {
@StateObject private var externalInvitations = ExternalInvitationController()
#if DIRECT_DISTRIBUTION && os(macOS)
// Sparkle auto-updater, present only in the direct-download (.dmg) build.
@StateObject private var updater = SparkleUpdaterController()
#endif
var body: some Scene {
#if os(macOS)
@@ -18,6 +22,11 @@ struct VniDropApp: App {
.ignoresSafeArea()
.onOpenURL(perform: openInvitation)
}
#if DIRECT_DISTRIBUTION
.commands {
UpdatesCommands(controller: updater)
}
#endif
#else
WindowGroup(id: mainWindowId) {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))

View File

@@ -0,0 +1,49 @@
#if DIRECT_DISTRIBUTION && os(macOS)
import Combine
import Sparkle
import SwiftUI
/// Owns the Sparkle updater for the direct-download (.dmg) build.
///
/// Compiled only under `DIRECT_DISTRIBUTION`, so the App Store / TestFlight target
/// (which must not ship a self-updater) never compiles or links Sparkle. The feed
/// URL and public EdDSA key are read from Info.plist (`SUFeedURL`, `SUPublicEDKey`).
@MainActor
final class SparkleUpdaterController: ObservableObject {
private let updaterController: SPUStandardUpdaterController
/// Mirrors `SPUUpdater.canCheckForUpdates` so the menu item can disable itself
/// while a check is already in flight.
@Published private(set) var canCheckForUpdates = false
init() {
// `startingUpdater: true` begins the automatic background check schedule.
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
updaterController.updater
.publisher(for: \.canCheckForUpdates)
.assign(to: &$canCheckForUpdates)
}
func checkForUpdates() {
updaterController.checkForUpdates(nil)
}
}
/// Adds a "Check for Updates" item to the application menu (right after the
/// standard "About VniDrop" item), matching the macOS convention.
struct UpdatesCommands: Commands {
@ObservedObject var controller: SparkleUpdaterController
var body: some Commands {
CommandGroup(after: .appInfo) {
Button(String(localized: L10n.Updates.check)) {
controller.checkForUpdates()
}
.disabled(!controller.canCheckForUpdates)
}
}
}
#endif

View File

@@ -57,6 +57,20 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<!-- Sparkle auto-update (direct-download .dmg build only). These keys are inert
in the App Store build, which never loads Sparkle (DIRECT_DISTRIBUTION off).
The feed is appcast.xml attached as an asset to each GitHub Release; the
/releases/latest/download/ path always redirects to the newest (non-
prerelease) release's copy, and its <enclosure> points at that same release's
.dmg — no GitHub Pages or repo commits needed. SUPublicEDKey must be the EdDSA
public key printed by Sparkle's `generate_keys` — replace the placeholder
before shipping (see apple/RELEASE-MACOS.md). -->
<key>SUFeedURL</key>
<string>https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>/vcOgyrhPi3e58yL8M7hZvDCOgsAKyBsQu/7ChAUk1M=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NFCReaderUsageDescription</key>

View File

@@ -0,0 +1,15 @@
<?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">
<!-- Entitlements for the direct-download (Developer ID + notarized) macOS build.
Deliberately NOT sandboxed: a sandboxed app signed for Developer ID requires a
provisioning profile, whereas direct-distribution apps run outside the App
Store sandbox by convention. Gatekeeper trust here comes from the hardened
runtime (ENABLE_HARDENED_RUNTIME) plus notarization, not the sandbox. The App
Store target (VniDrop) keeps VniDrop.entitlements with the sandbox enabled.
Networking and user file access need no entitlements once unsandboxed. -->
<dict>
</dict>
</plist>

View File

@@ -12,6 +12,15 @@ options:
macOS: "15.0"
createIntermediateGroups: true
# Build configurations. Declaring `configs` replaces XcodeGen's Debug/Release
# defaults, so both are re-listed here. `Release-Direct` is a release-type config
# used only by the VniDropDirect (notarized DMG + Sparkle) target; the App Store
# `VniDrop` target ships under plain `Release`.
configs:
Debug: debug
Release: release
Release-Direct: release
# Project-wide build settings (applied to every target/config).
settings:
base:
@@ -26,27 +35,44 @@ packages:
SFSafeSymbols:
url: https://github.com/SFSafeSymbols/SFSafeSymbols
from: "5.3.0"
# Sparkle powers in-app auto-updates for the direct-download (.dmg) build only.
# It is linked exclusively by the VniDropDirect target — SwiftPM links products
# per target, not per config, so keeping it off the App Store target is what
# guarantees the store binary never bundles a self-updater (App Store forbids it).
Sparkle:
url: https://github.com/sparkle-project/Sparkle
from: "2.9.4"
targets:
VniDrop:
# Shared definition for the two shipping app targets. `VniDrop` (App Store /
# TestFlight) and `VniDropDirect` (notarized DMG + Sparkle) build the exact same
# sources; only their destinations, extra dependencies, and the DIRECT_DISTRIBUTION
# compile flag differ (set per target below).
targetTemplates:
AppBase:
type: application
supportedDestinations: [iOS, macOS]
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources:
- path: VniDrop
excludes:
- "Resources/Info.plist"
- "Resources/VniDrop.entitlements"
- "Resources/VniDropDirect.entitlements"
- "Resources/**/.DS_Store"
settings:
base:
PRODUCT_NAME: VniDrop
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "7"
# Placeholder only — the real CFBundleVersion is stamped at build time as a
# UTC YYMMDD.HHMM timestamp by the "Stamp build number" phase below, so every
# build is monotonic and self-describing (shown as "MARKETING_VERSION (build)").
CURRENT_PROJECT_VERSION: "1"
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
# (the editor reads these build settings, not the manual plist).
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
@@ -59,12 +85,11 @@ targets:
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
configs:
debug:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
release:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Produce a dSYM in the archive so symbol upload succeeds.
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
release-direct:
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
dependencies:
- package: VnidropCore
- package: SFSafeSymbols
@@ -86,6 +111,52 @@ targets:
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
exit 1
fi
postBuildScripts:
# Stamp CFBundleVersion as a UTC YYMMDD.HHMM timestamp into the built
# Info.plist before code signing. Runs for every build (Xcode GUI archive and
# CLI alike), so both the App Store and direct-download channels get a
# monotonic, meaningful build id. CI/reproducible builds can pin it via the
# VNIDROP_BUILD env var. MARKETING_VERSION stays the human X.Y.Z version.
- name: Stamp build number (UTC timestamp)
basedOnDependencyAnalysis: false
script: |
build="${VNIDROP_BUILD:-$(date -u +%y%m%d.%H%M)}"
plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
if [ -f "$plist" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build" "$plist"
echo "Stamped CFBundleVersion = $build"
else
echo "warning: Info.plist not found at $plist; CFBundleVersion not stamped"
fi
targets:
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
VniDrop:
templates: [AppBase]
supportedDestinations: [iOS, macOS]
# Direct-download macOS target: Developer ID signed, notarized, ships in a .dmg
# and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the
# App Store target above never compiles or links it.
VniDropDirect:
templates: [AppBase]
supportedDestinations: [macOS]
settings:
base:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
# Notarization requires the hardened runtime.
ENABLE_HARDENED_RUNTIME: YES
# Non-sandboxed entitlements: a sandboxed Developer ID app needs a
# provisioning profile, which direct distribution avoids. (App Store target
# keeps VniDrop.entitlements with the sandbox.)
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
# link doesn't fail looking for x86_64 symbols.
ARCHS: arm64
dependencies:
- package: Sparkle
VniDropTests:
type: bundle.unit-test
@@ -93,6 +164,7 @@ targets:
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources:
- path: Tests
settings:
@@ -120,3 +192,15 @@ schemes:
config: Release
archive:
config: Release
# Direct-download build: always the Release-Direct config (Sparkle + notarization).
VniDropDirect:
build:
targets:
VniDropDirect: all
run:
config: Release-Direct
profile:
config: Release-Direct
archive:
config: Release-Direct

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/"