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