oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1@preconcurrency import CallsBridge
2import Foundation
3import Security
4
5/// The sign-in the app keeps between launches: the instance, and the
6/// session cookie the browser handoff minted for it, in the JSON shape
7/// the bridge already reads. It is the whole of what the app needs to
8/// be signed in again, and so it is a credential and is kept like one.
9public struct StoredSession: Codable, Equatable, Sendable {
10 public let origin: String
11 public let cookies: String
12
13 public init(origin: String, cookies: String) {
14 self.origin = origin
15 self.cookies = cookies
16 }
17}
18
19/// Where the kept sign-in lives. A protocol so the model can be tested
20/// without touching the Keychain of whoever runs the tests.
21///
22/// One sign-in at a time: saving replaces whatever was there. The app
23/// signs in to one instance at a time too, and a second slot would be a
24/// credential nobody remembers is there.
25public protocol SessionStore: Sendable {
26 func load() -> StoredSession?
27 func save(_ session: StoredSession)
28 func clear()
29}
30
31/// The Keychain, as a generic password readable only on this device and
32/// only while it is unlocked — the session never reaches a backup or
33/// another device, and a phone left locked in a drawer does not hand it
34/// to anything running in the background, which this app never does.
35///
36/// Failures are silent on purpose. A sign-in that could not be kept is
37/// one the person makes again next launch, which is exactly where every
38/// launch was before this existed.
39public struct KeychainSessionStore: SessionStore {
40 private let service: String
41 private let account = "session"
42
43 public init(service: String = "net.amadan.oficina.calls") {
44 self.service = service
45 }
46
47 public func load() -> StoredSession? {
48 for keychain in Self.keychains {
49 var query = base(keychain)
50 query[kSecReturnData as String] = true
51 query[kSecMatchLimit as String] = kSecMatchLimitOne
52 var result: CFTypeRef?
53 guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
54 let data = result as? Data
55 else { continue }
56 return try? JSONDecoder().decode(StoredSession.self, from: data)
57 }
58 return nil
59 }
60
61 public func save(_ session: StoredSession) {
62 guard let data = try? JSONEncoder().encode(session) else { return }
63 clear()
64 for keychain in Self.keychains {
65 var item = base(keychain)
66 item[kSecValueData as String] = data
67 item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
68 // The first keychain that takes it is the one it lives in. On a Mac
69 // that is the data protection keychain when the build is entitled
70 // to it, and the login keychain when it is not.
71 if SecItemAdd(item as CFDictionary, nil) == errSecSuccess { return }
72 }
73 }
74
75 public func clear() {
76 for keychain in Self.keychains {
77 SecItemDelete(base(keychain) as CFDictionary)
78 }
79 }
80
81 private enum Keychain { case dataProtection, legacy }
82
83 /// iPhone has one keychain. A Mac has two, and the one that honours
84 /// this-device-only needs a keychain-access-groups entitlement, which
85 /// an ad hoc signed development build does not carry: there the add
86 /// fails with errSecMissingEntitlement and the item goes to the login
87 /// keychain instead, protected by the login password and the app's
88 /// code signature rather than by the device.
89 private static var keychains: [Keychain] {
90 #if os(macOS)
91 return [.dataProtection, .legacy]
92 #else
93 return [.dataProtection]
94 #endif
95 }
96
97 private func base(_ keychain: Keychain) -> [String: Any] {
98 var query: [String: Any] = [
99 kSecClass as String: kSecClassGenericPassword,
100 kSecAttrService as String: service,
101 kSecAttrAccount as String: account,
102 ]
103 #if os(macOS)
104 query[kSecUseDataProtectionKeychain as String] = keychain == .dataProtection
105 #endif
106 return query
107 }
108}
109
110/// The three requests the model makes about a session it holds, as
111/// closures over the bridge so tests can answer them without a server.
112/// Each runs off the main actor: they are network calls.
113struct SessionRemote: Sendable {
114 /// "member", "participant" or "signed-out"; throws only when the
115 /// instance could not be asked. See bridge.CheckSession.
116 var check: @Sendable (_ origin: String, _ cookies: String) throws -> String
117 /// JSON: {"room": ...} or {"signedOut": true}. See bridge.OpenLink.
118 var open: @Sendable (_ origin: String, _ cookies: String, _ address: String) throws -> String
119 /// Best effort; see bridge.SignOut.
120 var signOut: @Sendable (_ origin: String, _ cookies: String) -> Void
121 /// Starts the browser handoff for an address: an ordinary one with no
122 /// offer, or — for a link from the web's "Join in the app" — one the
123 /// browser that made the offer confirms. See bridge.BeginAuthentication
124 /// and bridge.BeginOfferAuthentication.
125 var begin: @Sendable (_ address: String, _ offer: String?) throws -> any Handoff = { address, offer in
126 var error: NSError?
127 let auth =
128 if let offer {
129 BridgeBeginOfferAuthentication(address, offer, &error)
130 } else {
131 BridgeBeginAuthentication(address, &error)
132 }
133 guard let auth else { throw error ?? NSError(domain: "Calls", code: 1) }
134 return BridgeHandoff(auth)
135 }
136
137 static let member = "member"
138 static let participant = "participant"
139 static let signedOut = "signed-out"
140
141 static let bridge = SessionRemote(
142 check: { origin, cookies in
143 var error: NSError?
144 let standing = BridgeCheckSession(origin, cookies, &error)
145 if let error { throw error }
146 return standing
147 },
148 open: { origin, cookies, address in
149 var error: NSError?
150 let answer = BridgeOpenLink(origin, cookies, address, &error)
151 if let error { throw error }
152 return answer
153 },
154 signOut: { origin, cookies in
155 // gomobile renders a func returning only error as BOOL plus an
156 // NSErrorPointer. Both are dropped: the app is signed out whatever
157 // the instance answers.
158 var error: NSError?
159 BridgeSignOut(origin, cookies, &error)
160 })
161}
162
163/// One browser handoff in progress: where its browser goes, the code both
164/// sides show, and the wait for the session the browser approves. A
165/// protocol so the model's handling of it can be tested without a server.
166protocol Handoff: AnyObject, Sendable {
167 func browserURL() -> String
168 func confirmationCode() -> String
169 /// Blocks until the browser approves, refuses or the handoff expires;
170 /// the JSON a sign-in hands back (see CallsModel.adopt).
171 func wait() throws -> String
172 func cancel()
173}
174
175/// The bridge's handoff. gomobile's class is safe to use from any thread
176/// (the Go side locks); it is simply not marked so.
177final class BridgeHandoff: Handoff, @unchecked Sendable {
178 private let auth: BridgeAuthentication
179 init(_ auth: BridgeAuthentication) { self.auth = auth }
180 func browserURL() -> String { auth.browserURL() }
181 func confirmationCode() -> String { auth.confirmationCode() }
182 func wait() throws -> String {
183 var error: NSError?
184 let result = auth.wait(&error)
185 if let error { throw error }
186 return result
187 }
188 func cancel() { auth.cancel() }
189}
190