diff --git a/Package.swift b/Package.swift
index c8a2e028..b348d8ee 100644
--- a/Package.swift
+++ b/Package.swift
@@ -85,6 +85,11 @@ let package = Package(
dependencies: ["OpenLessPolish"],
path: "Tests/OpenLessPolishTests"
),
+ .testTarget(
+ name: "OpenLessAppTests",
+ dependencies: ["OpenLessApp"],
+ path: "Tests/OpenLessAppTests"
+ ),
],
// 暂留 Swift 5 语言模式,避免一次性吞掉 Swift 6 严格并发的全部改造。
// 升 .v6 时需要重新审视 Recorder / VolcengineStreamingASR 等 @unchecked Sendable 类。
diff --git a/Sources/OpenLessApp/AppDelegate.swift b/Sources/OpenLessApp/AppDelegate.swift
index 7edd3c8c..44363510 100644
--- a/Sources/OpenLessApp/AppDelegate.swift
+++ b/Sources/OpenLessApp/AppDelegate.swift
@@ -11,6 +11,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var updaterController: UpdaterController?
func applicationDidFinishLaunching(_ notification: Notification) {
+ VersionedPermissionRefresh().resetIfNeeded()
+
// 不依赖 UserPreferences flag:每次启动直接查实际权限。
// ad-hoc 签名下每次 rebuild 二进制 hash 都变,TCC 会自动失效,
// 必须在那种情况下重新进引导,否则用户看不到 menu bar 也就罢了,
diff --git a/Sources/OpenLessApp/Permissions/TCCPermissionResetter.swift b/Sources/OpenLessApp/Permissions/TCCPermissionResetter.swift
new file mode 100644
index 00000000..1b947ca9
--- /dev/null
+++ b/Sources/OpenLessApp/Permissions/TCCPermissionResetter.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+struct TCCPermissionResetter {
+ static let services = [
+ "Accessibility",
+ "Microphone",
+ "AppleEvents",
+ "ListenEvent",
+ ]
+
+ let bundleIdentifier: String
+ let resetService: (String, String) -> Void
+
+ init(
+ bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "com.openless.app",
+ resetService: @escaping (String, String) -> Void = TCCPermissionResetter.runTCCUtilReset
+ ) {
+ self.bundleIdentifier = bundleIdentifier
+ self.resetService = resetService
+ }
+
+ func resetAll() {
+ for service in Self.services {
+ resetService(service, bundleIdentifier)
+ }
+ }
+
+ private static func runTCCUtilReset(service: String, bundleIdentifier: String) {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
+ process.arguments = ["reset", service, bundleIdentifier]
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+
+ if process.terminationStatus == 0 {
+ Log.write("[permissions] reset TCC \(service) for \(bundleIdentifier)")
+ } else {
+ Log.write("[permissions] tccutil reset \(service) for \(bundleIdentifier) exited with \(process.terminationStatus)")
+ }
+ } catch {
+ Log.write("[permissions] failed to run tccutil for \(service): \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/Sources/OpenLessApp/Permissions/VersionedPermissionRefresh.swift b/Sources/OpenLessApp/Permissions/VersionedPermissionRefresh.swift
new file mode 100644
index 00000000..0ace4a0d
--- /dev/null
+++ b/Sources/OpenLessApp/Permissions/VersionedPermissionRefresh.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+struct VersionedPermissionRefresh {
+ static let lastResetVersionKey = "openless.permissions.last_tcc_reset_version"
+
+ let currentVersion: String
+ let defaults: UserDefaults
+ let resetter: TCCPermissionResetter
+ let isRunningFromAppBundle: Bool
+
+ init(
+ currentVersion: String = VersionedPermissionRefresh.currentBundleVersion(),
+ defaults: UserDefaults = .standard,
+ resetter: TCCPermissionResetter = TCCPermissionResetter(),
+ isRunningFromAppBundle: Bool = VersionedPermissionRefresh.isRunningFromAppBundle()
+ ) {
+ self.currentVersion = currentVersion
+ self.defaults = defaults
+ self.resetter = resetter
+ self.isRunningFromAppBundle = isRunningFromAppBundle
+ }
+
+ func resetIfNeeded() {
+ guard isRunningFromAppBundle else {
+ Log.write("[permissions] skip versioned TCC reset outside app bundle")
+ return
+ }
+
+ guard defaults.string(forKey: Self.lastResetVersionKey) != currentVersion else {
+ return
+ }
+
+ Log.write("[permissions] bundle version changed; clearing old TCC approvals before permission onboarding")
+ resetter.resetAll()
+ defaults.set(currentVersion, forKey: Self.lastResetVersionKey)
+ }
+
+ static func currentBundleVersion(bundle: Bundle = .main) -> String {
+ let info = bundle.infoDictionary
+ let shortVersion = info?["CFBundleShortVersionString"] as? String
+ let buildNumber = info?["CFBundleVersion"] as? String
+
+ switch (shortVersion, buildNumber) {
+ case let (.some(shortVersion), .some(buildNumber)):
+ return "\(shortVersion)#\(buildNumber)"
+ case let (.some(shortVersion), .none):
+ return shortVersion
+ case let (.none, .some(buildNumber)):
+ return buildNumber
+ case (.none, .none):
+ return "unknown"
+ }
+ }
+
+ static func isRunningFromAppBundle(bundle: Bundle = .main) -> Bool {
+ bundle.bundleURL.pathExtension == "app"
+ }
+}
diff --git a/Tests/OpenLessAppTests/TCCPermissionResetterTests.swift b/Tests/OpenLessAppTests/TCCPermissionResetterTests.swift
new file mode 100644
index 00000000..39c1cf9c
--- /dev/null
+++ b/Tests/OpenLessAppTests/TCCPermissionResetterTests.swift
@@ -0,0 +1,24 @@
+import XCTest
+@testable import OpenLessApp
+
+final class TCCPermissionResetterTests: XCTestCase {
+ func test_resetAllClearsEveryTCCServiceForBundleIdentifier() {
+ var resetCalls: [(service: String, bundleIdentifier: String)] = []
+ let resetter = TCCPermissionResetter(
+ bundleIdentifier: "com.example.openless",
+ resetService: { service, bundleIdentifier in
+ resetCalls.append((service, bundleIdentifier))
+ }
+ )
+
+ resetter.resetAll()
+
+ XCTAssertEqual(resetCalls.map(\.service), [
+ "Accessibility",
+ "Microphone",
+ "AppleEvents",
+ "ListenEvent",
+ ])
+ XCTAssertEqual(resetCalls.map(\.bundleIdentifier), Array(repeating: "com.example.openless", count: 4))
+ }
+}
diff --git a/Tests/OpenLessAppTests/VersionedPermissionRefreshTests.swift b/Tests/OpenLessAppTests/VersionedPermissionRefreshTests.swift
new file mode 100644
index 00000000..8412de07
--- /dev/null
+++ b/Tests/OpenLessAppTests/VersionedPermissionRefreshTests.swift
@@ -0,0 +1,64 @@
+import XCTest
+@testable import OpenLessApp
+
+final class VersionedPermissionRefreshTests: XCTestCase {
+ func test_resetIfNeededClearsPermissionsAndStoresCurrentVersionWhenVersionChanged() {
+ let defaults = makeDefaults()
+ defaults.set("1.0.0#1", forKey: VersionedPermissionRefresh.lastResetVersionKey)
+ var didReset = false
+
+ let refresh = VersionedPermissionRefresh(
+ currentVersion: "1.0.1#2",
+ defaults: defaults,
+ resetter: TCCPermissionResetter(resetService: { _, _ in didReset = true }),
+ isRunningFromAppBundle: true
+ )
+
+ refresh.resetIfNeeded()
+
+ XCTAssertTrue(didReset)
+ XCTAssertEqual(defaults.string(forKey: VersionedPermissionRefresh.lastResetVersionKey), "1.0.1#2")
+ }
+
+ func test_resetIfNeededDoesNothingWhenVersionWasAlreadyHandled() {
+ let defaults = makeDefaults()
+ defaults.set("1.0.1#2", forKey: VersionedPermissionRefresh.lastResetVersionKey)
+ var resetCount = 0
+
+ let refresh = VersionedPermissionRefresh(
+ currentVersion: "1.0.1#2",
+ defaults: defaults,
+ resetter: TCCPermissionResetter(resetService: { _, _ in resetCount += 1 }),
+ isRunningFromAppBundle: true
+ )
+
+ refresh.resetIfNeeded()
+
+ XCTAssertEqual(resetCount, 0)
+ XCTAssertEqual(defaults.string(forKey: VersionedPermissionRefresh.lastResetVersionKey), "1.0.1#2")
+ }
+
+ func test_resetIfNeededSkipsWhenNotRunningFromAppBundle() {
+ let defaults = makeDefaults()
+ var resetCount = 0
+
+ let refresh = VersionedPermissionRefresh(
+ currentVersion: "1.0.1#2",
+ defaults: defaults,
+ resetter: TCCPermissionResetter(resetService: { _, _ in resetCount += 1 }),
+ isRunningFromAppBundle: false
+ )
+
+ refresh.resetIfNeeded()
+
+ XCTAssertEqual(resetCount, 0)
+ XCTAssertNil(defaults.string(forKey: VersionedPermissionRefresh.lastResetVersionKey))
+ }
+
+ private func makeDefaults() -> UserDefaults {
+ let suiteName = "OpenLessAppTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ return defaults
+ }
+}
diff --git a/appcast.xml b/appcast.xml
index c974b413..025b1071 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -5,6 +5,41 @@
https://raw.githubusercontent.com/appergb/openless/main/appcast.xml
OpenLess update feed
+ -
+ OpenLess 1.0.07
+ Tue, 28 Apr 2026 09:36:07 +0000
+ B1006
+ 1.0.07
+ 15.0
+
+
+
+
-
OpenLess 1.0.06
Tue, 28 Apr 2026 05:36:01 +0000
diff --git a/docs/release-notes/1.0.07.md b/docs/release-notes/1.0.07.md
new file mode 100644
index 00000000..d6858db3
--- /dev/null
+++ b/docs/release-notes/1.0.07.md
@@ -0,0 +1,22 @@
+# OpenLess 1.0.07
+
+build B1006 — 修复更新后权限不会重新进入授权流程的问题。
+
+## 修复
+
+- **新版本安装后自动清理旧权限**。OpenLess 从 `.app` bundle 启动时会比较当前版本和上次已处理版本;发现新版本后,先自动清理旧的 Accessibility、Microphone、AppleEvents、ListenEvent TCC 授权记录。
+- **清理后自动进入权限请求流程**。旧权限清掉后,启动流程继续按实际授权状态判断;缺少辅助功能或麦克风权限时,会直接显示现有授权引导窗口,让用户为新版本重新授权。
+- **同一版本不重复清理**。授权完成后再次打开同一版本不会反复 reset;下一个版本安装后会再次执行同样逻辑。
+- **开发运行更安全**。只有从 `.app` bundle 启动时才触发版本化权限清理,避免 `swift run` 等开发调试误清理已安装应用的权限。
+
+## 安装
+
+**老用户**:启动 OpenLess 后自动检查更新。安装新版本并重新打开后,会按新版本权限流程重新授权。
+
+**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
+
+```bash
+xattr -dr com.apple.quarantine /Applications/OpenLess.app
+```
+
+之后双击启动。
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
index 981cbb90..7ac37fe0 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -6,8 +6,8 @@ cd "$(dirname "$0")/.."
APP_NAME="OpenLess"
BUNDLE_ID="com.openless.app"
-APP_VERSION="1.0.06"
-BUILD_NUMBER="B1005"
+APP_VERSION="1.0.07"
+BUILD_NUMBER="B1006"
BUILD_DIR="build"
APP_DIR="${BUILD_DIR}/${APP_NAME}.app"
BIN_DIR="${APP_DIR}/Contents/MacOS"