From c9534b091523a3d0b70d05cd735b164d6ae4cb75 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 27 Apr 2026 18:45:26 +0800 Subject: [PATCH 1/2] feat: integrate Sparkle 2 for auto-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [] [] 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 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. --- Package.swift | 4 + Sources/OpenLessApp/AppDelegate.swift | 5 +- Sources/OpenLessApp/ApplicationMenu.swift | 14 +- .../Updater/UpdaterController.swift | 26 ++++ appcast.xml | 9 ++ scripts/build-app.sh | 41 ++++- scripts/release.sh | 142 ++++++++++++++++++ 7 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 Sources/OpenLessApp/Updater/UpdaterController.swift create mode 100644 appcast.xml create mode 100755 scripts/release.sh diff --git a/Package.swift b/Package.swift index 059e6396..2046d43d 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,9 @@ let package = Package( .library(name: "OpenLessInsertion", targets: ["OpenLessInsertion"]), .library(name: "OpenLessPersistence", targets: ["OpenLessPersistence"]), ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"), + ], targets: [ .target(name: "OpenLessCore", path: "Sources/OpenLessCore"), .target( @@ -63,6 +66,7 @@ let package = Package( "OpenLessPolish", "OpenLessInsertion", "OpenLessPersistence", + .product(name: "Sparkle", package: "Sparkle"), ], path: "Sources/OpenLessApp" ), diff --git a/Sources/OpenLessApp/AppDelegate.swift b/Sources/OpenLessApp/AppDelegate.swift index d30775af..d8963a6f 100644 --- a/Sources/OpenLessApp/AppDelegate.swift +++ b/Sources/OpenLessApp/AppDelegate.swift @@ -8,6 +8,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var coordinator: DictationCoordinator? private var menuBar: MenuBarController? private var onboarding: OnboardingWindowController? + private var updaterController: UpdaterController? func applicationDidFinishLaunching(_ notification: Notification) { // 不依赖 UserPreferences flag:每次启动直接查实际权限。 @@ -18,7 +19,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { showOnboarding() return } - ApplicationMenu.install() + let updater = UpdaterController() + self.updaterController = updater + ApplicationMenu.install(updaterTarget: updater) let coordinator = DictationCoordinator() let menuBar = MenuBarController(coordinator: coordinator) coordinator.menuBar = menuBar diff --git a/Sources/OpenLessApp/ApplicationMenu.swift b/Sources/OpenLessApp/ApplicationMenu.swift index fc2952ac..2c8f3c92 100644 --- a/Sources/OpenLessApp/ApplicationMenu.swift +++ b/Sources/OpenLessApp/ApplicationMenu.swift @@ -2,11 +2,23 @@ import AppKit @MainActor enum ApplicationMenu { - static func install() { + static func install(updaterTarget: UpdaterController? = nil) { let mainMenu = NSMenu() let appItem = NSMenuItem() let appMenu = NSMenu(title: "OpenLess") + + if let updaterTarget { + let checkUpdates = NSMenuItem( + title: "检查更新…", + action: #selector(UpdaterController.checkForUpdates(_:)), + keyEquivalent: "" + ) + checkUpdates.target = updaterTarget + appMenu.addItem(checkUpdates) + appMenu.addItem(.separator()) + } + appMenu.addItem(NSMenuItem(title: "Hide OpenLess", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")) appMenu.addItem(NSMenuItem(title: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h")) appMenu.items.last?.keyEquivalentModifierMask = [.command, .option] diff --git a/Sources/OpenLessApp/Updater/UpdaterController.swift b/Sources/OpenLessApp/Updater/UpdaterController.swift new file mode 100644 index 00000000..895180a3 --- /dev/null +++ b/Sources/OpenLessApp/Updater/UpdaterController.swift @@ -0,0 +1,26 @@ +import AppKit +import Sparkle + +/// Sparkle 更新通道:启动时自动检查、菜单「检查更新…」手动触发、新版本弹窗由 Sparkle 内置 UI 提供。 +/// 默认配置: +/// - SUFeedURL:appcast.xml 的 raw GitHub URL(写在 Info.plist) +/// - SUPublicEDKey:EdDSA 公钥(写在 Info.plist);私钥只在发版机的 Keychain 里 +/// - 启动后台后约 30s 做首次检查;之后每小时一次(也可在 Info.plist 调整) +@MainActor +final class UpdaterController: NSObject { + let updater: SPUStandardUpdaterController + + override init() { + self.updater = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + super.init() + } + + /// 菜单项 target = self,action = #selector(checkForUpdates(_:)) + @objc func checkForUpdates(_ sender: Any?) { + updater.checkForUpdates(sender) + } +} diff --git a/appcast.xml b/appcast.xml new file mode 100644 index 00000000..c4e71071 --- /dev/null +++ b/appcast.xml @@ -0,0 +1,9 @@ + + + + OpenLess + https://raw.githubusercontent.com/appergb/openless/main/appcast.xml + OpenLess update feed + zh-CN + + diff --git a/scripts/build-app.sh b/scripts/build-app.sh index a132a2eb..e34eee1c 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -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" < @@ -58,11 +72,34 @@ cat > "${APP_DIR}/Contents/Info.plist" <OpenLess 需要麦克风权限以录制您的语音并转写为文字。 NSAppleEventsUsageDescription OpenLess 需要权限以将转写后的文字插入到您当前光标所在的输入框。 + SUFeedURL + ${SPARKLE_FEED_URL} + SUPublicEDKey + ${SPARKLE_PUBLIC_KEY} + SUEnableAutomaticChecks + + SUEnableInstallerLauncherService + + SUScheduledCheckInterval + 3600 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" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..5eaa79a9 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# 发新版的一条龙脚本。 +# +# 用法: +# ./scripts/release.sh [] [] +# +# 例: +# ./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-.zip +# 4. sign_update zip → 拿 EdDSA 签名 + 长度 +# 5. 在 appcast.xml 里追加新 +# 6. git commit appcast.xml + build-app.sh +# 7. git tag v + 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 [] []}" +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="" +else + NOTES_HTML="OpenLess ${VERSION}

]]>" +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 的 顶部插入新 +NEW_ITEM=$(cat < + OpenLess ${VERSION} + ${PUB_DATE} + ${BUILD} + ${VERSION} + 15.0 + ${NOTES_HTML} + + +EOF +) + +# 用 awk 在 OpenLess update feed 之后插入新 item +TMP_APPCAST=$(/usr/bin/mktemp) +/usr/bin/awk -v new_item="${NEW_ITEM}" ' + /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}" < Date: Mon, 27 Apr 2026 18:53:55 +0800 Subject: [PATCH 2/2] fix(sparkle): add @executable_path/../Frameworks rpath to OpenLessApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Package.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 2046d43d..c8a2e028 100644 --- a/Package.swift +++ b/Package.swift @@ -68,7 +68,12 @@ let package = Package( "OpenLessPersistence", .product(name: "Sparkle", package: "Sparkle"), ], - path: "Sources/OpenLessApp" + path: "Sources/OpenLessApp", + // 让 dyld 能在 Contents/Frameworks/ 里找到嵌入的 Sparkle.framework。 + // SPM 默认不给 executable 加这个 rpath,需要显式注入。 + linkerSettings: [ + .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"]) + ] ), .testTarget( name: "OpenLessCoreTests",