diff --git a/Package.swift b/Package.swift
index 059e6396..c8a2e028 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,8 +66,14 @@ let package = Package(
"OpenLessPolish",
"OpenLessInsertion",
"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",
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/scripts/build-app.sh b/scripts/build-app.sh
index d5db2bed..3db2239a 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" <