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: 4 additions & 3 deletions .envrc.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Copy to .envrc and fill in your values
# If using direnv, run: direnv allow

# Code signing (for local dev builds via `just run`)
export CODESIGN_IDENTITY="-" # ad-hoc signing, or "Developer ID Application: Your Name (TEAMID)"
export CODESIGN_TEAM_ID="" # your Apple team ID, leave empty for ad-hoc
# Code signing. Local dev auto-detects a Developer ID Application identity when available
# so `just dev` and `just test-update` share the same Accessibility permission scope.
export CODESIGN_IDENTITY="" # optional explicit "Developer ID Application: Example (TEAMID)"
export CODESIGN_TEAM_ID="" # your Apple team ID, leave empty for local-only builds

# Release only (for `just publish`)
export TAP_DIR="" # path to your local homebrew-tap checkout
10 changes: 10 additions & 0 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ else
AUTO_CHECKS=true
fi

if [[ -n "${SPARKLE_FEED_URL_OVERRIDE:-}" ]]; then
if [[ "$BUILD_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
# (see Scripts/test-update-flow.sh).
FEED_URL="$SPARKLE_FEED_URL_OVERRIDE"
fi

echo "Building $APP_NAME ($BUILD_CONFIG)..."

swift build -c "$BUILD_CONFIG" --product "$APP_NAME"
Expand Down
100 changes: 100 additions & 0 deletions Scripts/sign-dev-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail

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

REQUIRE_DEVELOPER_ID=false
if [[ "${1:-}" == "--require-developer-id" ]]; then
REQUIRE_DEVELOPER_ID=true
shift
fi

APP=${1:?"Usage: $0 [--require-developer-id] <path-to-app>"}
ENTITLEMENTS="$ROOT/build/MiniWhisper.entitlements"

if [[ ! -d "$APP" ]]; then
echo "App bundle not found: $APP" >&2
exit 1
fi

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

find_developer_id_identity() {
security find-identity -v -p codesigning \
| awk -F'"' '/Developer ID Application/ {print $2; exit}'
}

choose_identity() {
if [[ -n "${MINIWHISPER_DEV_CODESIGN_IDENTITY:-}" && "${MINIWHISPER_DEV_CODESIGN_IDENTITY}" != "-" ]]; then
printf '%s\n' "$MINIWHISPER_DEV_CODESIGN_IDENTITY"
return
fi

if [[ -n "${CODESIGN_IDENTITY:-}" && "${CODESIGN_IDENTITY}" != "-" ]]; then
printf '%s\n' "$CODESIGN_IDENTITY"
return
fi

local developer_id
developer_id=$(find_developer_id_identity)
if [[ -n "$developer_id" ]]; then
printf '%s\n' "$developer_id"
return
fi

if [[ -n "${DEV_CODESIGN_IDENTITY:-}" && "${DEV_CODESIGN_IDENTITY}" != "-" ]]; then
printf '%s\n' "$DEV_CODESIGN_IDENTITY"
return
fi

printf '%s\n' "-"
}

IDENTITY=$(choose_identity)
if [[ "$REQUIRE_DEVELOPER_ID" == true && "$IDENTITY" == "-" ]]; then
echo "No Developer ID Application identity found; Sparkle stays disabled without it." >&2
exit 1
fi

echo "==> Signing ${APP} with: ${IDENTITY}"

sign_if_present() {
local item="$1"
[[ -e "$item" ]] || return 0
codesign --force --sign "$IDENTITY" "$item"
}

SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
if [[ -d "$SPARKLE" ]]; then
for item in \
"$SPARKLE/Versions/B/Sparkle" \
"$SPARKLE/Versions/B/Autoupdate" \
"$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" \
"$SPARKLE/Versions/B/Updater.app" \
"$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \
"$SPARKLE/Versions/B/XPCServices/Downloader.xpc" \
"$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" \
"$SPARKLE/Versions/B/XPCServices/Installer.xpc" \
"$SPARKLE/Versions/B" \
"$SPARKLE"; do
sign_if_present "$item"
done
fi

sign_if_present "$APP/Contents/Frameworks/whisper.framework"
sign_if_present "$APP/Contents/Resources/miniwhispercli"

codesign --force --sign "$IDENTITY" \
--entitlements "$ENTITLEMENTS" \
"$APP"

if [[ "$REQUIRE_DEVELOPER_ID" == true ]]; then
signature_info=$(codesign -dvv "$APP" 2>&1)
if ! grep -q '^Authority=Developer ID Application:' <<<"$signature_info"; then
echo "Expected a Developer ID Application signature, but ${APP} was signed differently." >&2
exit 1
fi
fi
115 changes: 115 additions & 0 deletions Scripts/test-update-flow.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
#
# Local end-to-end test of the Sparkle update flow with the real updater:
#
# 1. Builds the current version as "MiniWhisper 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: open the menu popover, Check Now (footer → Settings, or the
# Settings window), and watch available → 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/MiniWhisper Dev.app"
DEV_EXEC="${INSTALL_PATH}/Contents/MacOS/MiniWhisper"

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/mw-update-test.XXXXXX)
VERSION_BACKUP=$(mktemp /tmp/mw-version-env.XXXXXX)
cp version.env "$VERSION_BACKUP"

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 "com.miniwhisper.dev" 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)
}

echo "==> Building current version (${MARKETING_VERSION}, build ${BUILD_NUMBER})..."
SPARKLE_FEED_URL_OVERRIDE="$FEED" bash Scripts/build-app.sh debug
bash Scripts/sign-dev-app.sh --require-developer-id "build/MiniWhisper.app"

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

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

echo "==> Generating signed appcast..."
/usr/bin/ditto -c -k --keepParent "build/MiniWhisper.app" \
"$SERVE_DIR/MiniWhisper-${NEW_MARKETING}.zip"
rm -rf "build/MiniWhisper.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 com.miniwhisper.dev "UpdateSimulatorScenario" 2>/dev/null || true

open "$INSTALL_PATH"

cat <<INSTRUCTIONS

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

Try it:
- Menu popover → footer Settings → "Check for Updates", or the Settings
window → "Check Now".
- Expect the banner: Update Available ${NEW_MARKETING} → Install →
Downloading → Preparing → Installing → app relaunches as ${NEW_MARKETING}.
- Scheduled background checks are enabled too; if you wait instead of
clicking, discovery arrives as a banner + notification.

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

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

wait "$SERVER_PID"
6 changes: 3 additions & 3 deletions Sources/MiniWhisper/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
) async {
let identifier = response.notification.request.identifier
guard identifier == UpdateNotification.identifier else { return }
// Re-enters the in-progress update session, bringing the Sparkle
// alert into focus.
// The update session is still pending in the updater's view model;
// opening the popover surfaces the banner with its Install action.
await MainActor.run {
self.updaterController.checkForUpdates(nil)
self.revealMenuBarInterface()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ final class DisabledUpdaterController: UpdaterProviding {
}
let isAvailable: Bool = false
let unavailableReason: String?
let updateStatus = UpdateStatus()
let updateViewModel = UpdateViewModel()

init(unavailableReason: String? = nil) {
self.unavailableReason = unavailableReason
Expand Down
Loading
Loading