@preconcurrency import CallsBridge import Foundation import Security /// The sign-in the app keeps between launches: the instance, and the /// session cookie the browser handoff minted for it, in the JSON shape /// the bridge already reads. It is the whole of what the app needs to /// be signed in again, and so it is a credential and is kept like one. public struct StoredSession: Codable, Equatable, Sendable { public let origin: String public let cookies: String public init(origin: String, cookies: String) { self.origin = origin self.cookies = cookies } } /// Where the kept sign-in lives. A protocol so the model can be tested /// without touching the Keychain of whoever runs the tests. /// /// One sign-in at a time: saving replaces whatever was there. The app /// signs in to one instance at a time too, and a second slot would be a /// credential nobody remembers is there. public protocol SessionStore: Sendable { func load() -> StoredSession? func save(_ session: StoredSession) func clear() } /// The Keychain, as a generic password readable only on this device and /// only while it is unlocked — the session never reaches a backup or /// another device, and a phone left locked in a drawer does not hand it /// to anything running in the background, which this app never does. /// /// Failures are silent on purpose. A sign-in that could not be kept is /// one the person makes again next launch, which is exactly where every /// launch was before this existed. public struct KeychainSessionStore: SessionStore { private let service: String private let account = "session" public init(service: String = "net.amadan.oficina.calls") { self.service = service } public func load() -> StoredSession? { for keychain in Self.keychains { var query = base(keychain) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { continue } return try? JSONDecoder().decode(StoredSession.self, from: data) } return nil } public func save(_ session: StoredSession) { guard let data = try? JSONEncoder().encode(session) else { return } clear() for keychain in Self.keychains { var item = base(keychain) item[kSecValueData as String] = data item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly // The first keychain that takes it is the one it lives in. On a Mac // that is the data protection keychain when the build is entitled // to it, and the login keychain when it is not. if SecItemAdd(item as CFDictionary, nil) == errSecSuccess { return } } } public func clear() { for keychain in Self.keychains { SecItemDelete(base(keychain) as CFDictionary) } } private enum Keychain { case dataProtection, legacy } /// iPhone has one keychain. A Mac has two, and the one that honours /// this-device-only needs a keychain-access-groups entitlement, which /// an ad hoc signed development build does not carry: there the add /// fails with errSecMissingEntitlement and the item goes to the login /// keychain instead, protected by the login password and the app's /// code signature rather than by the device. private static var keychains: [Keychain] { #if os(macOS) return [.dataProtection, .legacy] #else return [.dataProtection] #endif } private func base(_ keychain: Keychain) -> [String: Any] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] #if os(macOS) query[kSecUseDataProtectionKeychain as String] = keychain == .dataProtection #endif return query } } /// The three requests the model makes about a session it holds, as /// closures over the bridge so tests can answer them without a server. /// Each runs off the main actor: they are network calls. struct SessionRemote: Sendable { /// "member", "participant" or "signed-out"; throws only when the /// instance could not be asked. See bridge.CheckSession. var check: @Sendable (_ origin: String, _ cookies: String) throws -> String /// JSON: {"room": ...} or {"signedOut": true}. See bridge.OpenLink. var open: @Sendable (_ origin: String, _ cookies: String, _ address: String) throws -> String /// Best effort; see bridge.SignOut. var signOut: @Sendable (_ origin: String, _ cookies: String) -> Void /// Starts the browser handoff for an address: an ordinary one with no /// offer, or — for a link from the web's "Join in the app" — one the /// browser that made the offer confirms. See bridge.BeginAuthentication /// and bridge.BeginOfferAuthentication. var begin: @Sendable (_ address: String, _ offer: String?) throws -> any Handoff = { address, offer in var error: NSError? let auth = if let offer { BridgeBeginOfferAuthentication(address, offer, &error) } else { BridgeBeginAuthentication(address, &error) } guard let auth else { throw error ?? NSError(domain: "Calls", code: 1) } return BridgeHandoff(auth) } static let member = "member" static let participant = "participant" static let signedOut = "signed-out" static let bridge = SessionRemote( check: { origin, cookies in var error: NSError? let standing = BridgeCheckSession(origin, cookies, &error) if let error { throw error } return standing }, open: { origin, cookies, address in var error: NSError? let answer = BridgeOpenLink(origin, cookies, address, &error) if let error { throw error } return answer }, signOut: { origin, cookies in // gomobile renders a func returning only error as BOOL plus an // NSErrorPointer. Both are dropped: the app is signed out whatever // the instance answers. var error: NSError? BridgeSignOut(origin, cookies, &error) }) } /// One browser handoff in progress: where its browser goes, the code both /// sides show, and the wait for the session the browser approves. A /// protocol so the model's handling of it can be tested without a server. protocol Handoff: AnyObject, Sendable { func browserURL() -> String func confirmationCode() -> String /// Blocks until the browser approves, refuses or the handoff expires; /// the JSON a sign-in hands back (see CallsModel.adopt). func wait() throws -> String func cancel() } /// The bridge's handoff. gomobile's class is safe to use from any thread /// (the Go side locks); it is simply not marked so. final class BridgeHandoff: Handoff, @unchecked Sendable { private let auth: BridgeAuthentication init(_ auth: BridgeAuthentication) { self.auth = auth } func browserURL() -> String { auth.browserURL() } func confirmationCode() -> String { auth.confirmationCode() } func wait() throws -> String { var error: NSError? let result = auth.wait(&error) if let error { throw error } return result } func cancel() { auth.cancel() } }