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"