diff --git a/CHANGELOG.md b/CHANGELOG.md index b97b251..9271677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ 记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。 官网、构建与发布脚本等不影响应用本身的改动不在此记录。 +## 未发布 + +### 2026-09-21 + +#### 修复 + +- 账号登录后应用直接退出:在登录窗口里选完 GitHub、Google 或 Apple 账号,应用立即闪退,重新打开也没有登录状态。原因是系统在后台队列回调登录结果,而回调闭包带着主线程隔离,被运行时当场中止,令牌还没来得及换取和保存。现在回调改为不带隔离、收到后再回到主线程处理,并补了回归测试。 + ## 0.6.1 · 2026-09-18 ### 2026-09-18 diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index d9df347..2520883 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -30,7 +30,7 @@ let package = Package( .testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]), .testTarget(name: "UpdatesTests", dependencies: ["Updates"]), .testTarget(name: "AccountSyncTests", dependencies: ["AccountSync"]), - .testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics"]), + .testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics", "AccountSync"]), .testTarget(name: "LocalizationTests", dependencies: ["Localization"]), ], swiftLanguageModes: [.v6] diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift index be1cec4..391f033 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift @@ -116,9 +116,8 @@ public final class SyncController { let verifier = PKCE.verifier() let url = client.loginURL(provider: provider, challenge: PKCE.challenge(for: verifier)) let anchor = AnchorProvider(anchor: presentationAnchor) - let session = ASWebAuthenticationSession(url: url, callbackURLScheme: SyncClient.callbackScheme) { [weak self] callback, error in - Task { @MainActor in self?.finishSignIn(callback: callback, error: error, verifier: verifier) } - } + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: SyncClient.callbackScheme, + completionHandler: Self.completionHandler(for: self, verifier: verifier)) session.presentationContextProvider = anchor // 保留浏览器里已登录的第三方账号,用户不必再输一次密码 session.prefersEphemeralWebBrowserSession = false @@ -132,6 +131,15 @@ public final class SyncController { } } + /// 登录结束时系统在它自己的 XPC 队列上回调,不在主线程。这个闭包必须在非隔离的上下文里创建: + /// 直接写在 signIn 里会继承 @MainActor 隔离,Swift 6 在闭包入口检查执行器,发现不在主线程就中止进程 + /// (选完账号应用直接退出,令牌还没换到,重开也没有登录状态) + nonisolated static func completionHandler(for controller: SyncController, verifier: String) -> @Sendable (URL?, Error?) -> Void { + { [weak controller] callback, error in + Task { @MainActor in controller?.finishSignIn(callback: callback, error: error, verifier: verifier) } + } + } + public func cancelSignIn() { authSession?.cancel() authSession = nil diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift index 1f191c2..0732a9f 100644 --- a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift @@ -1,3 +1,4 @@ +import AccountSync import Foundation @testable import Metrics import Testing @@ -387,3 +388,28 @@ private func isolatedDefaults() -> UserDefaults { #expect(ranking.ranked(limit: 2).map(\.id) == ["z", "x"]) } } + +@MainActor +@Suite struct SyncSignInTests { + /// 系统在后台的 XPC 队列上回调登录结果。回调闭包如果带着主线程隔离,Swift 6 会在入口处中止进程, + /// 表现为选完账号应用直接退出;这里在后台队列调用一次,能跑完并回到主线程处理就算通过 + @Test func completionHandlerMayRunOffMainThread() async { + let controller = SyncController(settings: AppSettings(defaults: isolatedDefaults()), defaults: isolatedDefaults(), + tokens: MemoryTokenStore()) + let handler = SyncController.completionHandler(for: controller, verifier: "verifier") + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global().async { + handler(nil, nil) + continuation.resume() + } + } + // 没有回调地址按失败处理;处理发生在主线程的下一个任务里,稍等一下 + for _ in 0..<100 where controller.phase == .idle { + try? await Task.sleep(for: .milliseconds(10)) + } + guard case .failed = controller.phase else { + Issue.record("回调之后 phase 应为 failed,实际是 \(controller.phase)") + return + } + } +} diff --git a/server/sync/sync b/server/sync/sync deleted file mode 100755 index 863ce25..0000000 Binary files a/server/sync/sync and /dev/null differ