Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ import PackageDescription
let package = Package(
name: "CopyCat",
platforms: [.macOS(.v14)],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"),
],
targets: [
.executableTarget(
name: "CopyCat",
dependencies: [
.product(name: "Sparkle", package: "Sparkle"),
],
path: "Sources/CopyCat",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.define("ENABLE_SPARKLE"),
]),
.testTarget(
name: "CopyCatTests",
Expand Down
61 changes: 59 additions & 2 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ source "$ROOT/version.env"

# YYMMDDHHMM timestamp — unique, monotonic, debuggable. Avoids manual bumping
# and satisfies the App Store / notarization monotonic-build-number rule.
BUILD_NUMBER="$(date +%y%m%d%H%M)"
# Local Sparkle tests build old and new apps back-to-back, so they inject
# distinct build numbers instead of waiting for the next minute.
BUILD_NUMBER="${COPYCAT_BUILD_NUMBER:-$(date +%y%m%d%H%M)}"

APP_NAME="CopyCat"
CONFIG="${1:-debug}"
Expand All @@ -25,7 +27,8 @@ fi
cd "$ROOT"
swift build -c "$CONFIG" --product "$APP_NAME"

BIN_PATH="$ROOT/.build/$CONFIG/$APP_NAME"
BUILD_DIR="$ROOT/.build/$CONFIG"
BIN_PATH="$BUILD_DIR/$APP_NAME"
if [[ ! -x "$BIN_PATH" ]]; then
BIN_PATH="$(find "$ROOT/.build" -type f -path "*/$CONFIG/$APP_NAME" -print -quit || true)"
fi
Expand All @@ -37,9 +40,22 @@ fi
if [[ "$CONFIG" == "release" ]]; then
BUNDLE_ID="$RELEASE_BUNDLE_ID"
DISPLAY_NAME="CopyCat"
FEED_URL="https://raw.githubusercontent.com/andyhtran/CopyCat/main/appcast.xml"
AUTO_CHECKS=true
else
BUNDLE_ID="${RELEASE_BUNDLE_ID}.dev"
DISPLAY_NAME="CopyCat Dev"
FEED_URL=""
AUTO_CHECKS=false
fi

if [[ -n "${SPARKLE_FEED_URL_OVERRIDE:-}" ]]; then
if [[ "$CONFIG" != "debug" ]]; then
echo "SPARKLE_FEED_URL_OVERRIDE is only allowed for debug builds." >&2
exit 1
fi
# Local update-flow testing points the feed at a localhost appcast.
FEED_URL="$SPARKLE_FEED_URL_OVERRIDE"
fi

APP_BUNDLE="$ROOT/build/$APP_NAME.app"
Expand All @@ -58,6 +74,37 @@ for resource in AppIcon.icns MenuBarIcon.pdf; do
fi
done

# Embed Sparkle.framework.
FRAMEWORKS_DIR="$APP_BUNDLE/Contents/Frameworks"
if [[ -d "$BUILD_DIR/Sparkle.framework" ]]; then
mkdir -p "$FRAMEWORKS_DIR"
cp -R "$BUILD_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
chmod -R a+rX "$FRAMEWORKS_DIR/Sparkle.framework"
install_name_tool -add_rpath "@executable_path/../Frameworks" \
"$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true

SPARKLE_FW="$FRAMEWORKS_DIR/Sparkle.framework"

if [[ "$CONFIG" == "debug" ]]; then
CODESIGN_ARGS=(--force --sign "-")
else
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "${CODESIGN_IDENTITY:--}")
fi

resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }

resign "$SPARKLE_FW/Versions/B/Sparkle"
resign "$SPARKLE_FW/Versions/B/Autoupdate"
resign "$SPARKLE_FW/Versions/B/Updater.app/Contents/MacOS/Updater"
resign "$SPARKLE_FW/Versions/B/Updater.app"
resign "$SPARKLE_FW/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"
resign "$SPARKLE_FW/Versions/B/XPCServices/Downloader.xpc"
resign "$SPARKLE_FW/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer"
resign "$SPARKLE_FW/Versions/B/XPCServices/Installer.xpc"
resign "$SPARKLE_FW/Versions/B"
resign "$SPARKLE_FW"
fi

cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST
<?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">
Expand Down Expand Up @@ -93,6 +140,16 @@ cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>SUFeedURL</key>
<string>${FEED_URL}</string>
<key>SUPublicEDKey</key>
<string>${SU_PUBLIC_ED_KEY}</string>
<key>SUEnableAutomaticChecks</key>
<${AUTO_CHECKS}/>
<key>SUAutomaticallyUpdate</key>
<false/>
<key>SUAllowsAutomaticUpdates</key>
<false/>
</dict>
</plist>
PLIST
Expand Down
48 changes: 48 additions & 0 deletions Scripts/make-appcast.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/version.env"

ZIP=${1:?"Usage: $0 <CopyCat-<ver>.zip>"}
FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/andyhtran/CopyCat/main/appcast.xml}"
SPARKLE_CHANNEL="${SPARKLE_CHANNEL:-}"

if [[ ! -f "$ZIP" ]]; then
echo "Zip not found: $ZIP" >&2
exit 1
fi

if ! command -v generate_appcast &>/dev/null; then
echo "generate_appcast not found. Install: brew install andyhtran/tap/sparkle-tools" >&2
exit 1
fi

ZIP_NAME=$(basename "$ZIP")
DOWNLOAD_URL_PREFIX="${SPARKLE_DOWNLOAD_URL_PREFIX:-https://github.com/andyhtran/CopyCat/releases/download/v${MARKETING_VERSION}/}"

WORK_DIR=$(mktemp -d /tmp/appcast-gen.XXXXXX)
cleanup() { rm -rf "$WORK_DIR"; }
trap cleanup EXIT

if [[ -f "$ROOT/appcast.xml" ]]; then
cp "$ROOT/appcast.xml" "$WORK_DIR/appcast.xml"
fi
cp "$ZIP" "$WORK_DIR/$ZIP_NAME"

CHANNEL_ARGS=()
if [[ -n "$SPARKLE_CHANNEL" ]]; then
CHANNEL_ARGS=(--channel "$SPARKLE_CHANNEL")
fi

generate_appcast \
--download-url-prefix "$DOWNLOAD_URL_PREFIX" \
--embed-release-notes \
--link "$FEED_URL" \
"${CHANNEL_ARGS[@]}" \
"$WORK_DIR"

cp "$WORK_DIR/appcast.xml" "$ROOT/appcast.xml"

echo "Appcast updated: appcast.xml"
echo "Upload $ZIP_NAME to GitHub release, then commit appcast.xml."
127 changes: 127 additions & 0 deletions Scripts/test-update-flow.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
#
# Local end-to-end test of the Sparkle update flow with the real updater:
#
# 1. Builds the current version as "CopyCat Dev.app", Developer ID signed
# (required for UpdaterFactory to enable Sparkle), with its feed pointed
# at a localhost appcast.
# 2. Builds a version-bumped copy, signs it, zips it, and generates a
# signed appcast for it (requires the Sparkle EdDSA private key in the
# login Keychain, same as a real release).
# 3. Serves zip + appcast on localhost and launches the old version.
#
# From there: use the menu or Settings window to Check for Updates, click
# Install Update, and watch downloading → preparing → installing → relaunch as
# the bumped version. Ctrl-C stops the server.
#
# Nothing is committed or uploaded; version.env is restored on exit.
set -euo pipefail

ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "$ROOT"

PORT="${PORT:-8123}"
FEED="http://localhost:${PORT}/appcast.xml"
INSTALL_PATH="/Applications/CopyCat Dev.app"
DEV_EXEC="${INSTALL_PATH}/Contents/MacOS/CopyCat"
DEV_BUNDLE_ID="${COPYCAT_BUNDLE_ID:-com.copycat.macos.app}.dev"
SIGNING_ID="${CODESIGN_IDENTITY:?Set CODESIGN_IDENTITY to your Developer ID Application identity}"

if ! command -v generate_appcast &>/dev/null; then
echo "generate_appcast not found. Install: brew install andyhtran/tap/sparkle-tools" >&2
exit 1
fi

source version.env
SERVE_DIR=$(mktemp -d /tmp/copycat-update-test.XXXXXX)
VERSION_BACKUP=$(mktemp /tmp/copycat-version-env.XXXXXX)
cp version.env "$VERSION_BACKUP"
OLD_BUILD=$(date +%y%m%d%H%M%S)
NEW_BUILD=$((OLD_BUILD + 1))

SERVER_PID=""
cleanup() {
cp "$VERSION_BACKUP" version.env
rm -f "$VERSION_BACKUP"
rm -rf "$SERVE_DIR"
[[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true
}
trap cleanup EXIT

quit_dev_app() {
osascript -e "tell application id \"${DEV_BUNDLE_ID}\" to quit" \
>/dev/null 2>&1 || true
sleep 1
while read -r pid; do
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
done < <(pgrep -f "$DEV_EXEC" 2>/dev/null || true)
}

sign_dev_with_developer_id() {
local app=${1:?usage: sign_dev_with_developer_id <App.app>}
codesign --force --deep --timestamp --options runtime \
--sign "$SIGNING_ID" \
--entitlements "build/CopyCat.entitlements" \
"$app"
}

echo "==> Building current version (${MARKETING_VERSION}, build ${OLD_BUILD})..."
COPYCAT_BUILD_NUMBER="$OLD_BUILD" \
SPARKLE_FEED_URL_OVERRIDE="$FEED" bash Scripts/build-app.sh debug
sign_dev_with_developer_id "build/CopyCat.app"

echo "==> Installing to ${INSTALL_PATH}..."
quit_dev_app
rm -rf "$INSTALL_PATH"
cp -R "build/CopyCat.app" "$INSTALL_PATH"

NEW_MARKETING="${MARKETING_VERSION%.*}.$((${MARKETING_VERSION##*.} + 1))"
echo "==> Building update (${NEW_MARKETING}, build ${NEW_BUILD})..."
sed -i '' \
-e "s/^MARKETING_VERSION=.*/MARKETING_VERSION=${NEW_MARKETING}/" \
version.env
COPYCAT_BUILD_NUMBER="$NEW_BUILD" \
SPARKLE_FEED_URL_OVERRIDE="$FEED" bash Scripts/build-app.sh debug
cp "$VERSION_BACKUP" version.env
sign_dev_with_developer_id "build/CopyCat.app"

echo "==> Generating signed appcast..."
/usr/bin/ditto -c -k --keepParent "build/CopyCat.app" \
"$SERVE_DIR/CopyCat-${NEW_MARKETING}.zip"
rm -rf "build/CopyCat.app"
generate_appcast \
--download-url-prefix "http://localhost:${PORT}/" \
--link "$FEED" \
"$SERVE_DIR"

echo "==> Serving appcast on port ${PORT}..."
python3 -m http.server "$PORT" --directory "$SERVE_DIR" --bind 127.0.0.1 \
>/dev/null 2>&1 &
SERVER_PID=$!

# The debug-only UpdateSimulator shadows the real updater when its defaults
# key is set; a leftover key from a simulator session would silently turn
# this whole test into a simulation.
defaults delete "$DEV_BUNDLE_ID" "UpdateSimulatorScenario" 2>/dev/null || true

open "$INSTALL_PATH"

cat <<INSTRUCTIONS

Running: CopyCat Dev ${MARKETING_VERSION} (build ${OLD_BUILD})
Update: ${NEW_MARKETING} (build ${NEW_BUILD}) served at ${FEED}

Try it:
- Menu → "Check for Updates", or Settings → "Check Now".
- Expect the menu to show "Install Update ${NEW_MARKETING}" and "Later".
- Click Install Update, then watch Downloading → Preparing → Installing →
app relaunches as ${NEW_MARKETING}.
- Scheduled background checks are enabled too; if you wait instead of
clicking, discovery arrives as a menu item + notification.

Verify afterwards: Settings → About shows ${NEW_MARKETING}.

Ctrl-C stops the server (version.env already restored).
INSTRUCTIONS

wait "$SERVER_PID"
63 changes: 63 additions & 0 deletions Scripts/verify-appcast.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/version.env"

VERSION=${1:-$MARKETING_VERSION}
APPCAST="${ROOT}/appcast.xml"

if [[ ! -f "$APPCAST" ]]; then
echo "appcast.xml not found" >&2
exit 1
fi

if ! command -v sign_update &>/dev/null; then
echo "sign_update not found. Install: brew install andyhtran/tap/sparkle-tools" >&2
exit 1
fi

TMP_ZIP=$(mktemp /tmp/appcast-verify.XXXX.zip)
trap 'rm -f "$TMP_ZIP" "$TMP_ZIP.meta"' EXIT

python3 - "$APPCAST" "$VERSION" >"$TMP_ZIP.meta" <<'PY'
import sys, xml.etree.ElementTree as ET

appcast, version = sys.argv[1], sys.argv[2]
tree = ET.parse(appcast)
ns = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"}

for item in tree.getroot().findall("./channel/item"):
sv = item.findtext("sparkle:shortVersionString", default="", namespaces=ns)
if sv == version:
enc = item.find("enclosure")
url = enc.get("url")
sig = enc.get("{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature")
length = enc.get("length")
if not all([url, sig, length]):
sys.exit(f"Missing url/signature/length for version {version}")
print(url)
print(sig)
print(length)
sys.exit(0)

sys.exit(f"No appcast entry for version {version}")
PY

readarray -t META <"$TMP_ZIP.meta"
URL="${META[0]}"
SIG="${META[1]}"
LEN_EXPECTED="${META[2]}"

echo "Downloading: $URL"
curl -fSL -o "$TMP_ZIP" "$URL"

LEN_ACTUAL=$(stat -f%z "$TMP_ZIP")
if [[ "$LEN_ACTUAL" != "$LEN_EXPECTED" ]]; then
echo "Length mismatch: expected $LEN_EXPECTED, got $LEN_ACTUAL" >&2
exit 1
fi

echo "Verifying signature..."
sign_update --verify "$TMP_ZIP" "$SIG"
echo "Appcast entry for $VERSION verified."
Loading
Loading