feat: Sparkle 2 auto-update

* feat: integrate Sparkle 2 for auto-update

Adds the standard pieces for Sparkle-based silent updates:

- Package.swift: add Sparkle 2 SPM dep, wired into OpenLessApp.
- Sources/OpenLessApp/Updater/UpdaterController.swift: thin wrapper
  around SPUStandardUpdaterController. Owned by AppDelegate so the
  updater starts on app launch.
- AppDelegate: instantiate UpdaterController before installing the
  main menu; pass it to ApplicationMenu so the menu item can target
  it.
- ApplicationMenu: add "检查更新…" as the first item in the OpenLess
  menu when an UpdaterController target is present.
- scripts/build-app.sh: copy Sparkle.framework (with Updater.app +
  XPC services + Autoupdate) from the SPM artifacts dir into
  Contents/Frameworks/, inject SUFeedURL / SUPublicEDKey /
  SUEnableAutomaticChecks / SUEnableInstallerLauncherService /
  SUScheduledCheckInterval into Info.plist, and ad-hoc sign the
  embedded helpers explicitly (deep sign alone is unreliable for
  Sparkle's nested XPC bundles).
- scripts/release.sh: one-shot release pipeline.
  ./scripts/release.sh <version> [<build>] [<notes>]
  Bumps version in build-app.sh, builds, zips, signs the zip with the
  Keychain-stored EdDSA private key (auto-downloads sign_update from
  the Sparkle 2.9.1 release on first run), prepends a new <item> to
  appcast.xml, commits + tags + pushes main, and creates the GitHub
  release with the zip uploaded.
- appcast.xml: empty initial feed file at repo root, served via the
  raw.githubusercontent.com URL referenced by SUFeedURL. Items are
  appended automatically by release.sh.

Public EdDSA key embedded in the build:
iT00+eUw/55obn1suEnWqI7za2pc8mHIFIdRbOWXW1Q=

Private key lives only in the local Keychain on the release machine
(account: macOS login user; service: https://sparkle-project.org).
First-time install still requires the user to remove the quarantine
xattr (no Apple Developer ID); subsequent updates flow through
Sparkle's installer helper and bypass quarantine.

* fix(sparkle): add @executable_path/../Frameworks rpath to OpenLessApp

Without this, the SPM-built executable links Sparkle's symbols but
ships with no rpath that points at the embedded framework, so dyld
fails at launch with:

  Library not loaded: @rpath/Sparkle.framework/Versions/B/Sparkle

…and the user sees the macOS Gatekeeper-styled "请与开发者联系,以确保
OpenLess 可以配合此 macOS 版本使用" error. Inject the rpath via
linkerSettings.unsafeFlags so the binary itself knows where to look.

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
TRIPandbaiqing authored and GitHub committed 2026-04-27 18:58:37 +08:00
1 parent 56c3e09983
commit f38643f17c
6 files changed
+234 -5

No files matched your search

+39 -2
View File
@@ -12,6 +12,12 @@ BUILD_DIR="build"
APP_DIR="${BUILD_DIR}/${APP_NAME}.app"
BIN_DIR="${APP_DIR}/Contents/MacOS"
RES_DIR="${APP_DIR}/Contents/Resources"
FRAMEWORKS_DIR="${APP_DIR}/Contents/Frameworks"
# Sparkle 自动更新:appcast 文件的公开 URL + 锁定签名所用的 EdDSA 公钥。
# 公钥永远公开(验证下载内容用),私钥只在本机 Keychain,不进仓库。
SPARKLE_FEED_URL="https://raw.githubusercontent.com/appergb/openless/main/appcast.xml"
SPARKLE_PUBLIC_KEY="iT00+eUw/55obn1suEnWqI7za2pc8mHIFIdRbOWXW1Q="
echo "[build-app] generate app icon"
swift scripts/generate-app-icon.swift
@@ -24,11 +30,19 @@ BIN_SRC=".build/release/${APP_NAME}"
echo "[build-app] assemble bundle at ${APP_DIR}"
rm -rf "${APP_DIR}"
mkdir -p "${BIN_DIR}" "${RES_DIR}"
mkdir -p "${BIN_DIR}" "${RES_DIR}" "${FRAMEWORKS_DIR}"
cp "${BIN_SRC}" "${BIN_DIR}/${APP_NAME}"
cp "Resources/AppIcon.icns" "${RES_DIR}/AppIcon.icns"
cp "Resources/AppIcon.png" "${RES_DIR}/AppIcon.png"
# 嵌入 Sparkle.framework(含 Updater.app + XPC services + Autoupdate)。
# SPM 把 xcframework 解压到 .build/artifacts/sparkle/Sparkle/Sparkle.xcframework/,
# 选 macos-arm64_x86_64 那个 slice。整个 framework 一次性 cp -R 进去。
SPARKLE_FRAMEWORK_SRC=".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework"
[ -d "${SPARKLE_FRAMEWORK_SRC}" ] || { echo "missing ${SPARKLE_FRAMEWORK_SRC} — 跑过 swift build 没?"; exit 1; }
echo "[build-app] embed Sparkle.framework"
cp -R "${SPARKLE_FRAMEWORK_SRC}" "${FRAMEWORKS_DIR}/Sparkle.framework"
cat > "${APP_DIR}/Contents/Info.plist" <<EOF
<?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">
@@ -58,11 +72,34 @@ cat > "${APP_DIR}/Contents/Info.plist" <<EOF
<string>OpenLess 需要麦克风权限以录制您的语音并转写为文字。</string>
<key>NSAppleEventsUsageDescription</key>
<string>OpenLess 需要权限以将转写后的文字插入到您当前光标所在的输入框。</string>
<key>SUFeedURL</key>
<string>${SPARKLE_FEED_URL}</string>
<key>SUPublicEDKey</key>
<string>${SPARKLE_PUBLIC_KEY}</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>3600</integer>
</dict>
</plist>
EOF
echo "[build-app] ad-hoc code sign"
echo "[build-app] ad-hoc code sign (含 Sparkle helpers)"
# 顺序很重要:先签内层 helpers,最后签外层 .app;--deep 在 Sparkle 这种多层
# bundle 上不可靠(某些 XPC 会被漏签),所以显式逐个签。
SPARKLE_VERSIONS_DIR="${FRAMEWORKS_DIR}/Sparkle.framework/Versions/B"
codesign --force --sign - --timestamp=none \
"${SPARKLE_VERSIONS_DIR}/XPCServices/Installer.xpc" 2>/dev/null || true
codesign --force --sign - --timestamp=none \
"${SPARKLE_VERSIONS_DIR}/XPCServices/Downloader.xpc" 2>/dev/null || true
codesign --force --sign - --timestamp=none \
"${SPARKLE_VERSIONS_DIR}/Updater.app" 2>/dev/null || true
codesign --force --sign - --timestamp=none \
"${SPARKLE_VERSIONS_DIR}/Autoupdate" 2>/dev/null || true
codesign --force --sign - --timestamp=none \
"${FRAMEWORKS_DIR}/Sparkle.framework"
codesign --force --deep --sign - "${APP_DIR}"
echo "[build-app] kill old app"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# 发新版的一条龙脚本。
#
# 用法:
# ./scripts/release.sh <version> [<build_number>] [<release_notes_file>]
#
# 例:
# ./scripts/release.sh 1.0.02 A1004 docs/release-notes/1.0.02.md
# ./scripts/release.sh 1.0.02
#
# 流程:
# 1. 改 build-app.sh 里的 APP_VERSION / BUILD_NUMBER
# 2. 跑 build-app.sh 出 .app
# 3. ditto 打包成 OpenLess-<version>.zip
# 4. sign_update zip → 拿 EdDSA 签名 + 长度
# 5. 在 appcast.xml 里追加新 <item>
# 6. git commit appcast.xml + build-app.sh
# 7. git tag v<version> + push main + push tag
# 8. gh release create + 上传 zip
#
# 依赖:
# - /tmp/sparkle-tools/bin/sign_update(首次会自动从 Sparkle release 下载)
# - gh CLI 已登录
# - Keychain 里有 Sparkle EdDSA 私钥(已生成一次性)
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION="${1:?用法: ./scripts/release.sh <version> [<build_number>] [<release_notes_file>]}"
BUILD="${2:-}"
NOTES_FILE="${3:-}"
if [ -z "${BUILD}" ]; then
# 默认把 build number 设为 version 的简单变形(去掉点)。用户可显式覆盖。
BUILD="$(echo "${VERSION}" | tr -d '.')"
fi
APP_NAME="OpenLess"
APP_DIR="build/${APP_NAME}.app"
ZIP_NAME="${APP_NAME}-${VERSION}.zip"
ZIP_PATH="build/${ZIP_NAME}"
APPCAST="appcast.xml"
SPARKLE_TOOLS_DIR="/tmp/sparkle-tools"
SIGN_UPDATE="${SPARKLE_TOOLS_DIR}/bin/sign_update"
SPARKLE_VERSION="2.9.1"
# 1. 确保 sign_update 工具就位
if [ ! -x "${SIGN_UPDATE}" ]; then
echo "[release] 下载 Sparkle ${SPARKLE_VERSION} 工具..."
mkdir -p "${SPARKLE_TOOLS_DIR}"
(cd "${SPARKLE_TOOLS_DIR}" && \
gh release download "${SPARKLE_VERSION}" -R sparkle-project/Sparkle \
-p "Sparkle-${SPARKLE_VERSION}.tar.xz" --skip-existing && \
tar -xJf "Sparkle-${SPARKLE_VERSION}.tar.xz")
fi
# 2. 改 build-app.sh 里的版本
echo "[release] 写入版本号 ${VERSION} / ${BUILD}"
/usr/bin/sed -i '' "s/^APP_VERSION=.*/APP_VERSION=\"${VERSION}\"/" scripts/build-app.sh
/usr/bin/sed -i '' "s/^BUILD_NUMBER=.*/BUILD_NUMBER=\"${BUILD}\"/" scripts/build-app.sh
# 3. 构建 .app(不重置 TCC,避免本机权限被清掉)
echo "[release] 构建 .app"
RESET_TCC=0 ./scripts/build-app.sh > /dev/null
# 4. 打包 zip
rm -f "${ZIP_PATH}"
ditto -c -k --keepParent "${APP_DIR}" "${ZIP_PATH}"
ZIP_SIZE=$(/usr/bin/stat -f%z "${ZIP_PATH}")
echo "[release] zip = ${ZIP_PATH} (${ZIP_SIZE} bytes)"
# 5. 用 Keychain 里的 EdDSA 私钥签 zip
SIGN_OUTPUT=$("${SIGN_UPDATE}" "${ZIP_PATH}")
# 输出形如:sparkle:edSignature="xxxx" length="1234567"
EDSIG=$(echo "${SIGN_OUTPUT}" | /usr/bin/sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')
[ -n "${EDSIG}" ] || { echo "签名失败,sign_update 输出:${SIGN_OUTPUT}"; exit 1; }
echo "[release] EdDSA 签名 OK"
# 6. 准备 release notes(HTML 片段)
if [ -n "${NOTES_FILE}" ] && [ -f "${NOTES_FILE}" ]; then
NOTES_HTML="<![CDATA[$(cat "${NOTES_FILE}")]]>"
else
NOTES_HTML="<![CDATA[<p>OpenLess ${VERSION}</p>]]>"
fi
PUB_DATE=$(/bin/date -u +"%a, %d %b %Y %H:%M:%S +0000")
DOWNLOAD_URL="https://github.com/appergb/openless/releases/download/v${VERSION}/${ZIP_NAME}"
# 7. 在 appcast.xml 的 <channel> 顶部插入新 <item>
NEW_ITEM=$(cat <<EOF
<item>
<title>OpenLess ${VERSION}</title>
<pubDate>${PUB_DATE}</pubDate>
<sparkle:version>${BUILD}</sparkle:version>
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description>${NOTES_HTML}</description>
<enclosure
url="${DOWNLOAD_URL}"
length="${ZIP_SIZE}"
type="application/octet-stream"
sparkle:edSignature="${EDSIG}" />
</item>
EOF
)
# 用 awk 在 <description>OpenLess update feed</description> 之后插入新 item
TMP_APPCAST=$(/usr/bin/mktemp)
/usr/bin/awk -v new_item="${NEW_ITEM}" '
/<description>OpenLess update feed<\/description>/ {
print
print ""
print new_item
next
}
{ print }
' "${APPCAST}" > "${TMP_APPCAST}"
mv "${TMP_APPCAST}" "${APPCAST}"
echo "[release] appcast.xml 已追加 ${VERSION}"
# 8. git commit + tag + push
git add scripts/build-app.sh "${APPCAST}"
git commit -m "release: v${VERSION} (build ${BUILD})"
git tag "v${VERSION}"
git push origin main
git push origin "v${VERSION}"
# 9. gh release create + 上传 zip
gh release create "v${VERSION}" "${ZIP_PATH}" \
--title "OpenLess ${VERSION}" \
--notes-file "${NOTES_FILE:-/dev/stdin}" <<EOF
OpenLess ${VERSION} (build ${BUILD}).
老用户:app 启动后自动检查更新;首次安装见 README。
EOF
echo
echo "[release] 完成 ✅"
echo " Tag: v${VERSION}"
echo " Release: https://github.com/appergb/openless/releases/tag/v${VERSION}"
echo " Zip: ${ZIP_PATH} (${ZIP_SIZE} bytes)"
echo " Sig: ${EDSIG}"