import Foundation // 控件专用设备会话:主 app 经 /api/auth/device-session 给控制中心控件代铸的独立委派会话 // (独立 refresh,不与引擎抢轮换)。存 App Group 共享文件,控件读取后自行调 /api/clipboard、 // 401 即自刷并写回;refresh 被拒则清空,待主 app 下次启动重铸。 struct WidgetSession: Codable { var accessToken: String var refreshToken: String let userId: String } enum WidgetSessionStore { private static func fileURL() -> URL? { guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.identifier) else { return nil } return base.appendingPathComponent("widget_session.json") } static func load() -> WidgetSession? { guard let url = fileURL(), let data = try? Data(contentsOf: url) else { return nil } return try? JSONDecoder().decode(WidgetSession.self, from: data) } static func save(_ session: WidgetSession) { guard let url = fileURL(), let data = try? JSONEncoder().encode(session) else { return } // 首次解锁后即可读(含再次锁屏后):控制中心控件可从锁屏触发,用 .completeFileProtection // 会在锁屏时读不到 → 控件静默失效。UntilFirstUserAuthentication 仍加密、又覆盖锁屏调用。 try? data.write(to: url, options: [ .atomic, .completeFileProtectionUntilFirstUserAuthentication ]) } static func clear() { guard let url = fileURL() else { return } try? FileManager.default.removeItem(at: url) } // refreshAccess:用控件会话自身的 refresh token 换新 access(POST /api/auth/refresh),轮换后写回。 // 供主 app 在 content-available 后台唤醒 / 回前台时调用,使关闭态剪贴板会话保持可用——与引擎主会话 // 完全隔离,绝不触碰主 refresh,故不会致引擎令牌失同步而重登(#7)。无会话则 no-op。CAS-before-clear: // 仅当刷新被明确拒绝(401)且磁盘上仍是本次所用 refresh 时才清,避免误删并发轮换出的新会话。 static func refreshAccess() async { guard let session = load() else { return } var req = URLRequest(url: URL(string: CDropAPI.base + "/api/auth/refresh")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: [ "refresh_token": session.refreshToken ]) guard let (data, resp) = try? await URLSession.shared.data(for: req) else { return } let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 if code == 200, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let access = obj["access_token"] as? String { let newRefresh = (obj["refresh_token"] as? String) ?? session.refreshToken save(WidgetSession(accessToken: access, refreshToken: newRefresh, userId: session.userId)) } else if code == 401, load()?.refreshToken == session.refreshToken { clear() } } }