| 1 | import Combine |
| 2 | import Foundation |
| 3 | @preconcurrency import CallsBridge |
| 4 | |
| 5 | #if os(iOS) |
| 6 | import UIKit |
| 7 | #else |
| 8 | import AppKit |
| 9 | #endif |
| 10 | |
| 11 | @MainActor public final class CallsModel: ObservableObject { |
| 12 | /// The signed-in home's link field: a link someone sent, opened with |
| 13 | /// the session in hand. |
| 14 | @Published public var address = "" |
| 15 | /// The Calls server this app signs in to (see CallsServer). Changed only |
| 16 | /// through changeServer, never by a link. |
| 17 | @Published public private(set) var server: String |
| 18 | /// A meeting link waiting on a sign-in: one that arrived through |
| 19 | /// `oficina-calls://join`, on another server or with nobody signed in. |
| 20 | @Published public var pendingLink = "" |
| 21 | /// The pending link is on the instance this app is signed in to, but |
| 22 | /// the sign-in is somebody who cannot join that meeting — a guest |
| 23 | /// signed in on the web, say, with the app signed in as a member of |
| 24 | /// another team. Signing in for it is a visit: the app's own sign-in |
| 25 | /// stays. |
| 26 | @Published public var pendingNoAccess = false |
| 27 | /// The browser handoff under way was started by the web's "Join in the |
| 28 | /// app": the person confirms in the browser they came from, which is |
| 29 | /// already showing the code, rather than in one this app opens. |
| 30 | @Published public var handingOff = false |
| 31 | /// The offer from the link that launched the app while the kept |
| 32 | /// sign-in was still being checked, for when the check is done. |
| 33 | private var pendingOffer: String? |
| 34 | /// Signed in for one meeting only — as a guest, or on a server that is |
| 35 | /// not this app's. Such a session is never kept, and leaving the |
| 36 | /// meeting drops it and comes back to the server's own home. |
| 37 | @Published public var visiting = false |
| 38 | @Published public var error = "" |
| 39 | @Published public var signin: URL? |
| 40 | @Published public var authenticating = false |
| 41 | @Published public var confirmationCode = "" |
| 42 | private var authentication: (any Handoff)? |
| 43 | private var authEpoch = UUID() |
| 44 | private var callEpoch = UUID() |
| 45 | @Published public var signedIn = false |
| 46 | @Published public var joining = false { |
| 47 | didSet { updateWake() } |
| 48 | } |
| 49 | /// The display kept awake through the call, Mac only (CallWake). |
| 50 | let wake = CallWake() |
| 51 | private var wakeWatch: AnyCancellable? |
| 52 | /// A meeting is starting on the server; there is nothing to join yet. |
| 53 | @Published public var starting = false |
| 54 | /// A link is being opened on the instance this app is signed in to, |
| 55 | /// with the session it already holds. |
| 56 | @Published public var opening = false |
| 57 | /// The sign-in kept from last time is being read and checked with its |
| 58 | /// instance. Nothing is offered until it answers, because what is |
| 59 | /// offered depends on the answer. |
| 60 | @Published public var restoring = false |
| 61 | /// The kept sign-in could not be checked — no network, or a server |
| 62 | /// that did not answer — and is being used unchecked. It is kept, |
| 63 | /// because a bad connection says nothing about a session, and the |
| 64 | /// lobby offers to check again. |
| 65 | @Published public var unchecked = false |
| 66 | /// The server has not said this session cannot start meetings. The |
| 67 | /// session is the person's whole session however sign-in began — a |
| 68 | /// link names where the browser lands, not what the session may do — |
| 69 | /// and the server's requireMember is the gate that matters. This goes |
| 70 | /// down only when the server has answered that the session is not a |
| 71 | /// member's, so the button is not offered to someone it would refuse. |
| 72 | @Published public var canStart = false |
| 73 | /// Empty while signed in with no meeting: the state a person is in |
| 74 | /// between starting one and finishing the last. |
| 75 | @Published public var room = "" |
| 76 | @Published public var origin = "" |
| 77 | public let call = Call() |
| 78 | /// Internal rather than private so tests can hand the model a session |
| 79 | /// without a browser; nothing outside CallsKit sees it. |
| 80 | var cookies = "" |
| 81 | private let store: SessionStore |
| 82 | private let remote: SessionRemote |
| 83 | private let defaults: UserDefaults |
| 84 | /// Moved on by anything that makes the kept sign-in's answer stale — |
| 85 | /// a fresh sign-in, a sign-out — so a check still in flight lands on |
| 86 | /// nothing. |
| 87 | private var sessionEpoch = UUID() |
| 88 | /// A meeting link arrived while the kept sign-in was still being |
| 89 | /// checked: the app was opened *by* the link. It is opened as soon as |
| 90 | /// the check says the session is good, rather than asking again. |
| 91 | private var openAfterRestore = false |
| 92 | |
| 93 | public convenience init( |
| 94 | store: SessionStore = KeychainSessionStore(), defaults: UserDefaults = .standard |
| 95 | ) { |
| 96 | self.init(store: store, remote: .bridge, defaults: defaults) |
| 97 | } |
| 98 | |
| 99 | init(store: SessionStore, remote: SessionRemote, defaults: UserDefaults = .standard) { |
| 100 | self.store = store |
| 101 | self.remote = remote |
| 102 | self.defaults = defaults |
| 103 | server = CallsServer.load(from: defaults) |
| 104 | // A call that ends (the meeting ended, or this person was removed) |
| 105 | // lets the display sleep even before it is left. |
| 106 | wakeWatch = call.$state.sink { [weak self] state in self?.updateWake(phase: state?.phase) } |
| 107 | } |
| 108 | |
| 109 | private func updateWake(phase: String? = nil) { |
| 110 | #if os(macOS) |
| 111 | wake.update(inCall: CallWake.inCall(joining: joining, phase: phase ?? call.state?.phase)) |
| 112 | #endif |
| 113 | } |
| 114 | |
| 115 | /// The server's host, for the line that names it. |
| 116 | public var serverHost: String { CallsServer.host(of: server) } |
| 117 | |
| 118 | /// The host a pending link is on. |
| 119 | public var pendingHost: String { CallsServer.host(of: Self.instance(of: pendingLink) ?? "") } |
| 120 | |
| 121 | /// Whether the pending link is on this app's own server. |
| 122 | public var pendingOnServer: Bool { Self.instance(of: pendingLink) == server } |
| 123 | |
| 124 | /// The instance's host, for a line that says where this app is signed |
| 125 | /// in. The scheme is dropped: it is https for everyone but a developer |
| 126 | /// on loopback, and naming it would only make the line longer. |
| 127 | public var host: String { |
| 128 | URLComponents(string: origin)?.host ?? origin |
| 129 | } |
| 130 | |
| 131 | public func openMeetingURL(_ url: URL) { |
| 132 | guard !joining else { |
| 133 | error = "Leave the current meeting before opening another." |
| 134 | return |
| 135 | } |
| 136 | guard url.scheme == "oficina-calls", url.host == "join", |
| 137 | let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| 138 | let target = components.queryItems?.first(where: { $0.name == "url" })?.value, |
| 139 | case .success(let meeting) = MeetingLink.validate(target, allowLoopback: false) |
| 140 | else { |
| 141 | error = "This meeting link is not valid." |
| 142 | return |
| 143 | } |
| 144 | // The web's "Join in the app" (2026-10-02) adds the offer its |
| 145 | // signed-in browser made: the way to this person's own standing in |
| 146 | // that meeting, confirmed in that browser. An offer that is not the |
| 147 | // shape the server makes is ignored, and the link is an ordinary one. |
| 148 | let offer = components.queryItems?.first(where: { $0.name == "offer" })?.value |
| 149 | .flatMap { Self.validOffer($0) ? $0 : nil } |
| 150 | let address = meeting.absoluteString |
| 151 | pendingNoAccess = false |
| 152 | // The link launched the app, and the kept sign-in is still being |
| 153 | // checked. check() opens it once the answer is in. |
| 154 | if restoring { |
| 155 | pendingLink = address |
| 156 | pendingOffer = offer |
| 157 | openAfterRestore = true |
| 158 | return |
| 159 | } |
| 160 | // Already signed in to that instance: the session in hand is tried |
| 161 | // first, so a member opening their own meeting needs no browser at |
| 162 | // all. open() falls back to the offer, or a sign-in, if it cannot. |
| 163 | if signedIn, Self.instance(of: address) == origin { |
| 164 | pendingLink = "" |
| 165 | open(address, offer: offer) |
| 166 | return |
| 167 | } |
| 168 | cancelSignIn() |
| 169 | error = "" |
| 170 | // From the web: the person is signed in in the browser they came |
| 171 | // from, and confirming there is the whole of it. Started rather than |
| 172 | // offered — they asked for this meeting in the app a moment ago — and |
| 173 | // a visit unless it is on this app's own server. |
| 174 | if let offer { |
| 175 | pendingLink = "" |
| 176 | begin(address, visit: Self.instance(of: address) != server, retry: "", offer: offer) |
| 177 | return |
| 178 | } |
| 179 | // Another server, or nobody signed in: that is a sign-in, offered |
| 180 | // rather than started, and whatever session is in hand stays until a |
| 181 | // new one replaces it. A link never changes the server setting. |
| 182 | pendingLink = address |
| 183 | } |
| 184 | |
| 185 | /// An offer is 32 random bytes, base64url without padding. |
| 186 | nonisolated static func validOffer(_ offer: String) -> Bool { |
| 187 | offer.count == 43 |
| 188 | && offer.unicodeScalars.allSatisfy { |
| 189 | CharacterSet.alphanumerics.contains($0) && $0.isASCII || $0 == "-" || $0 == "_" |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// The signed-out home's one button: sign in to this app's server, or — |
| 194 | /// when a link is waiting — to the link's server, landing on its meeting. |
| 195 | public func signIn() { |
| 196 | let link = pendingLink |
| 197 | guard !link.isEmpty else { |
| 198 | begin(server, visit: false, retry: "Choose Sign in to try again.") |
| 199 | return |
| 200 | } |
| 201 | begin( |
| 202 | link, visit: pendingNoAccess || Self.instance(of: link) != server, |
| 203 | retry: "Choose Sign in to try again.") |
| 204 | } |
| 205 | |
| 206 | /// Puts the waiting link down without signing in. |
| 207 | public func dismissPendingLink() { |
| 208 | pendingLink = "" |
| 209 | pendingNoAccess = false |
| 210 | pendingOffer = nil |
| 211 | openAfterRestore = false |
| 212 | } |
| 213 | |
| 214 | /// Joins a meeting as a guest from a pasted invite link: that meeting's |
| 215 | /// own browser sign-in, on whatever server the link names. A guest's |
| 216 | /// session is for that meeting only — it is not kept, and it does not |
| 217 | /// change the server setting. Nil on success, otherwise what is wrong |
| 218 | /// with the link. |
| 219 | @discardableResult public func joinAsGuest(_ text: String) -> MeetingLink.Problem? { |
| 220 | switch MeetingLink.validate(text) { |
| 221 | case .failure(let problem): return problem |
| 222 | case .success(let link): |
| 223 | pendingLink = "" |
| 224 | begin( |
| 225 | link.absoluteString, visit: true, |
| 226 | retry: "Choose Join a call as a guest to try again.") |
| 227 | return nil |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | /// The signed-in home's link field. A link on the instance in hand opens |
| 232 | /// with its session; anything else is a sign-in for it, kept only when |
| 233 | /// it is on this app's server. |
| 234 | public func connect() { |
| 235 | let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) |
| 236 | if signedIn, Self.instance(of: address) == origin { |
| 237 | open(address) |
| 238 | return |
| 239 | } |
| 240 | begin(address, visit: Self.instance(of: address) != server, retry: "") |
| 241 | } |
| 242 | |
| 243 | /// The browser handoff for an address: the server itself, or a meeting |
| 244 | /// link on any server. A visit's session is never kept; `retry` is |
| 245 | /// appended to a failure so the error points at the way to try again. |
| 246 | /// |
| 247 | /// With an `offer` (the web's "Join in the app") the browser the person |
| 248 | /// came from confirms it — it is already waiting for this request — so |
| 249 | /// no browser is opened here, and once the session is in hand the |
| 250 | /// meeting is joined, which is what they asked for. |
| 251 | private func begin(_ address: String, visit: Bool, retry: String, offer: String? = nil) { |
| 252 | cancelSignIn() |
| 253 | sessionEpoch = UUID() |
| 254 | restoring = false |
| 255 | authenticating = true |
| 256 | handingOff = offer != nil |
| 257 | pendingOffer = nil |
| 258 | error = "" |
| 259 | let epoch = authEpoch |
| 260 | let remote = self.remote |
| 261 | Task { |
| 262 | do { |
| 263 | let auth = try await Task.detached { try remote.begin(address, offer) }.value |
| 264 | guard epoch == authEpoch else { |
| 265 | auth.cancel() |
| 266 | return |
| 267 | } |
| 268 | authentication = auth |
| 269 | let url = URL(string: auth.browserURL()) |
| 270 | if offer == nil { |
| 271 | guard let url, await openBrowser(url) else { |
| 272 | auth.cancel() |
| 273 | throw NSError( |
| 274 | domain: "Calls", code: 2, |
| 275 | userInfo: [NSLocalizedDescriptionKey: "Could not open your browser."]) |
| 276 | } |
| 277 | } |
| 278 | guard epoch == authEpoch else { |
| 279 | auth.cancel() |
| 280 | return |
| 281 | } |
| 282 | confirmationCode = auth.confirmationCode() |
| 283 | signin = url |
| 284 | let result = try await Task.detached { try auth.wait() }.value |
| 285 | guard epoch == authEpoch else { return } |
| 286 | try adopt(result, visit: visit) |
| 287 | if offer != nil, !room.isEmpty { join() } |
| 288 | #if os(macOS) |
| 289 | NSApplication.shared.activate(ignoringOtherApps: true) |
| 290 | #endif |
| 291 | } catch { |
| 292 | guard epoch == authEpoch else { return } |
| 293 | self.error = [error.localizedDescription, retry].filter { !$0.isEmpty }.joined( |
| 294 | separator: " ") |
| 295 | cancelSignIn() |
| 296 | // The offer has gone: the meeting is still there to sign in to. |
| 297 | if offer != nil { pendingLink = address } |
| 298 | } |
| 299 | } |
| 300 | } |
| 301 | /// Takes on the session a browser sign-in handed back. A visit's — |
| 302 | /// a guest's, or one on a server that is not this app's — is used for |
| 303 | /// its meeting and never kept; the server's own goes to keep(), which |
| 304 | /// keeps it if it is a member's. |
| 305 | func adopt(_ result: String, visit: Bool) throws { |
| 306 | let response = try JSONDecoder().decode(SignInResult.self, from: Data(result.utf8)) |
| 307 | cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! |
| 308 | origin = response.origin |
| 309 | room = response.room |
| 310 | // Offered whether the address was a link or the instance: see |
| 311 | // canStart. keep() takes it back if the server says otherwise. |
| 312 | // A visit is for its one meeting, so it starts nothing. |
| 313 | let visiting = visit || response.origin != server |
| 314 | canStart = !visiting |
| 315 | unchecked = false |
| 316 | signedIn = true |
| 317 | self.visiting = visiting |
| 318 | address = "" |
| 319 | pendingLink = "" |
| 320 | pendingNoAccess = false |
| 321 | authenticating = false |
| 322 | handingOff = false |
| 323 | signin = nil |
| 324 | authentication = nil |
| 325 | if !visiting { keep(StoredSession(origin: origin, cookies: cookies)) } |
| 326 | } |
| 327 | |
| 328 | public func openBrowser(_ url: URL) async -> Bool { |
| 329 | #if os(iOS) |
| 330 | return await UIApplication.shared.open(url) |
| 331 | #else |
| 332 | return NSWorkspace.shared.open(url) |
| 333 | #endif |
| 334 | } |
| 335 | public func cancelSignIn() { |
| 336 | authEpoch = UUID() |
| 337 | starting = false |
| 338 | authentication?.cancel() |
| 339 | authentication = nil |
| 340 | authenticating = false |
| 341 | handingOff = false |
| 342 | confirmationCode = "" |
| 343 | signin = nil |
| 344 | } |
| 345 | private struct SignInResult: Decodable { |
| 346 | let origin: String |
| 347 | let room: String |
| 348 | let cookies: [Cookie] |
| 349 | struct Cookie: Codable { |
| 350 | let name: String |
| 351 | let value: String |
| 352 | } |
| 353 | } |
| 354 | /// Creates a meeting on the signed-in instance and joins it. The |
| 355 | /// server decides the title, in its own zone, so a Mac in another one |
| 356 | /// cannot name a meeting an hour it is not. |
| 357 | /// |
| 358 | /// The identifier is kept after the call ends rather than cleared, so |
| 359 | /// leaving and rejoining the meeting you started is one button and not |
| 360 | /// a second meeting. Starting again simply replaces it. |
| 361 | public func startMeeting() { |
| 362 | guard !starting, !joining else { return } |
| 363 | guard canStart else { |
| 364 | error = "This account cannot start meetings on that Calls server." |
| 365 | return |
| 366 | } |
| 367 | starting = true |
| 368 | error = "" |
| 369 | let origin = self.origin, cookies = self.cookies |
| 370 | let operation = callEpoch |
| 371 | Task { |
| 372 | do { |
| 373 | let sqid = try await Task.detached { |
| 374 | var error: NSError? |
| 375 | // The device's zone, so the title the server writes is the |
| 376 | // time here ("Call, 2 Oct 15:40"). |
| 377 | let sqid = BridgeStartMeeting(origin, cookies, TimeZone.current.identifier, &error) |
| 378 | if let error { throw error } |
| 379 | return sqid |
| 380 | }.value |
| 381 | // A leave or a second start while the server was answering: the |
| 382 | // meeting exists and is simply not the one this app is on. It is |
| 383 | // left alone rather than ended, because ending someone else's |
| 384 | // newer meeting is the worse mistake. |
| 385 | guard operation == callEpoch else { return } |
| 386 | starting = false |
| 387 | room = sqid |
| 388 | join() |
| 389 | } catch { |
| 390 | guard operation == callEpoch else { return } |
| 391 | self.error = error.localizedDescription |
| 392 | starting = false |
| 393 | } |
| 394 | } |
| 395 | } |
| 396 | // MARK: The kept sign-in |
| 397 | |
| 398 | /// Reads the sign-in kept from last time and checks it with its |
| 399 | /// instance. Called once, when the app's window appears; a model that |
| 400 | /// is never asked — a snapshot, a test — never touches the Keychain. |
| 401 | public func restore() { |
| 402 | guard !signedIn, !authenticating, !restoring else { return } |
| 403 | restoring = true |
| 404 | let epoch = sessionEpoch |
| 405 | let store = self.store |
| 406 | Task { |
| 407 | let kept = await Task.detached { store.load() }.value |
| 408 | guard epoch == sessionEpoch else { return } |
| 409 | guard let kept else { |
| 410 | restoring = false |
| 411 | openAfterRestore = false |
| 412 | continueOffer() |
| 413 | return |
| 414 | } |
| 415 | // Kept for a server that is no longer this app's — the setting was |
| 416 | // changed outside the app. It is signed out, as changing it in |
| 417 | // Settings would have done. |
| 418 | guard kept.origin == server else { |
| 419 | restoring = false |
| 420 | openAfterRestore = false |
| 421 | let remote = self.remote |
| 422 | Task.detached { |
| 423 | store.clear() |
| 424 | remote.signOut(kept.origin, kept.cookies) |
| 425 | } |
| 426 | continueOffer() |
| 427 | return |
| 428 | } |
| 429 | origin = kept.origin |
| 430 | cookies = kept.cookies |
| 431 | check(kept, epoch: epoch) |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | /// Asks again after a check that could not be made. |
| 436 | public func recheck() { |
| 437 | guard signedIn, unchecked, !restoring else { return } |
| 438 | restoring = true |
| 439 | error = "" |
| 440 | check(StoredSession(origin: origin, cookies: cookies), epoch: sessionEpoch) |
| 441 | } |
| 442 | |
| 443 | private func check(_ kept: StoredSession, epoch: UUID) { |
| 444 | let remote = self.remote |
| 445 | Task { |
| 446 | let answer = await Task.detached { Result { try remote.check(kept.origin, kept.cookies) } } |
| 447 | .value |
| 448 | guard epoch == sessionEpoch else { return } |
| 449 | restoring = false |
| 450 | switch answer { |
| 451 | case .success(SessionRemote.member): |
| 452 | signedIn = true |
| 453 | visiting = false |
| 454 | unchecked = false |
| 455 | canStart = true |
| 456 | error = "" |
| 457 | if openAfterRestore, Self.instance(of: pendingLink) == origin { |
| 458 | let link = pendingLink, offer = pendingOffer |
| 459 | pendingLink = "" |
| 460 | pendingOffer = nil |
| 461 | open(link, offer: offer) |
| 462 | } else if openAfterRestore { |
| 463 | continueOffer() |
| 464 | } |
| 465 | case .success(SessionRemote.signedOut): |
| 466 | // The instance says the session has gone: thirty days passed, or |
| 467 | // it was signed out elsewhere. Forget it; signing in again is the |
| 468 | // home's one button. |
| 469 | let host = self.host |
| 470 | let offered = openAfterRestore |
| 471 | forget() |
| 472 | error = "Your sign-in to \(host) has ended. Choose Sign in to continue." |
| 473 | if offered { continueOffer() } |
| 474 | case .success: |
| 475 | // Live, but no longer a member's: only a member's session is |
| 476 | // kept, so this one is not either. |
| 477 | let offered = openAfterRestore |
| 478 | forget() |
| 479 | if offered { continueOffer() } |
| 480 | case .failure(let failure): |
| 481 | // Not an answer about the session. Keep it and use it; every |
| 482 | // request it makes is gated on the server regardless. |
| 483 | signedIn = true |
| 484 | visiting = false |
| 485 | unchecked = true |
| 486 | canStart = true |
| 487 | error = "Could not check your sign-in to \(host): \(failure.localizedDescription)" |
| 488 | // The link that launched the app is still tried with it: open() |
| 489 | // falls back to a browser sign-in if the server says it has ended. |
| 490 | if openAfterRestore, Self.instance(of: pendingLink) == origin { |
| 491 | let link = pendingLink, offer = pendingOffer |
| 492 | pendingLink = "" |
| 493 | pendingOffer = nil |
| 494 | open(link, offer: offer) |
| 495 | } else if openAfterRestore { |
| 496 | continueOffer() |
| 497 | } |
| 498 | } |
| 499 | openAfterRestore = false |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | /// A link from the web's "Join in the app" launched the app, and there |
| 504 | /// is no sign-in it can use: the offer is taken up now, as it would |
| 505 | /// have been had the app already been running. |
| 506 | private func continueOffer() { |
| 507 | guard let offer = pendingOffer, !pendingLink.isEmpty else { return } |
| 508 | let link = pendingLink |
| 509 | pendingOffer = nil |
| 510 | pendingLink = "" |
| 511 | begin(link, visit: Self.instance(of: link) != server, retry: "", offer: offer) |
| 512 | } |
| 513 | |
| 514 | /// Keeps a fresh sign-in if it is a member's. Only a member's session |
| 515 | /// is worth waking up holding: a guest's, or an invited address's, is |
| 516 | /// good for the meeting it came in through and nothing else, and is |
| 517 | /// signed in again from the next link like it always was. |
| 518 | /// |
| 519 | /// A check that cannot be made keeps nothing. The cost is one more |
| 520 | /// browser sign-in next launch, and the alternative is keeping a |
| 521 | /// session nobody has looked at. |
| 522 | func keep(_ session: StoredSession) { |
| 523 | let epoch = sessionEpoch |
| 524 | let remote = self.remote, store = self.store |
| 525 | Task { |
| 526 | let answer = await Task.detached { try? remote.check(session.origin, session.cookies) }.value |
| 527 | guard epoch == sessionEpoch, cookies == session.cookies else { return } |
| 528 | switch answer { |
| 529 | case SessionRemote.member?: |
| 530 | // Only this app's server's session is kept: the Keychain holds |
| 531 | // one sign-in, and it is the server's. |
| 532 | guard session.origin == server else { return } |
| 533 | await Task.detached { store.save(session) }.value |
| 534 | case SessionRemote.participant?: |
| 535 | canStart = false |
| 536 | default: |
| 537 | break |
| 538 | } |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | /// Signs this app out: the kept copy is forgotten at once, and the |
| 543 | /// instance is asked to revoke the session so it is dead and not just |
| 544 | /// dropped. If the instance cannot be reached the session lapses at |
| 545 | /// its thirty days, and nothing on this device can use it before then. |
| 546 | /// |
| 547 | /// Signing out of a visit ends only the visit: the server's own kept |
| 548 | /// sign-in, if there is one, is untouched and comes back. |
| 549 | public func signOut() { |
| 550 | guard !joining else { return } |
| 551 | if visiting { |
| 552 | endVisit() |
| 553 | return |
| 554 | } |
| 555 | let origin = self.origin, cookies = self.cookies |
| 556 | let remote = self.remote |
| 557 | forget() |
| 558 | address = "" |
| 559 | error = "" |
| 560 | guard !origin.isEmpty, !cookies.isEmpty else { return } |
| 561 | Task.detached { remote.signOut(origin, cookies) } |
| 562 | } |
| 563 | |
| 564 | /// Drops a visit's session — revoked, as a sign-out would — and goes |
| 565 | /// back to the server's home: its kept sign-in if there is one, |
| 566 | /// otherwise signed out. |
| 567 | private func endVisit() { |
| 568 | let origin = self.origin, cookies = self.cookies |
| 569 | let remote = self.remote |
| 570 | forget(keeping: true) |
| 571 | address = "" |
| 572 | if !origin.isEmpty, !cookies.isEmpty { |
| 573 | Task.detached { remote.signOut(origin, cookies) } |
| 574 | } |
| 575 | restore() |
| 576 | } |
| 577 | |
| 578 | /// Whether changing the server would sign this app out. |
| 579 | public var changingServerSignsOut: Bool { signedIn || restoring } |
| 580 | |
| 581 | /// Moves the app to another Calls server. Nil on success, otherwise |
| 582 | /// what is wrong with the address. |
| 583 | /// |
| 584 | /// A session for the old server is signed out, as Sign out does: the |
| 585 | /// Keychain copy forgotten, the instance asked to revoke it. The person |
| 586 | /// has confirmed that before this is called (changingServerSignsOut). |
| 587 | @discardableResult public func changeServer(to text: String) -> CallsServer.Problem? { |
| 588 | let origin: String |
| 589 | switch CallsServer.validate(text) { |
| 590 | case .failure(let problem): return problem |
| 591 | case .success(let valid): origin = valid |
| 592 | } |
| 593 | guard !joining else { |
| 594 | error = "Leave the current meeting before changing the Calls server." |
| 595 | return nil |
| 596 | } |
| 597 | CallsServer.save(origin, to: defaults) |
| 598 | guard origin != server else { return nil } |
| 599 | server = origin |
| 600 | let held = cookies.isEmpty ? nil : StoredSession(origin: self.origin, cookies: cookies) |
| 601 | let store = self.store, remote = self.remote |
| 602 | forget(keeping: true) |
| 603 | address = "" |
| 604 | error = "" |
| 605 | Task.detached { |
| 606 | let kept = store.load() |
| 607 | store.clear() |
| 608 | for session in Set([kept, held].compactMap { $0 }.map { [$0.origin, $0.cookies] }) { |
| 609 | remote.signOut(session[0], session[1]) |
| 610 | } |
| 611 | } |
| 612 | return nil |
| 613 | } |
| 614 | |
| 615 | /// Everything a sign-in left behind, in memory and — unless `keeping`, |
| 616 | /// for a visit whose session was never kept — in the Keychain. |
| 617 | private func forget(keeping: Bool = false) { |
| 618 | openAfterRestore = false |
| 619 | sessionEpoch = UUID() |
| 620 | callEpoch = UUID() |
| 621 | cancelSignIn() |
| 622 | if !keeping { |
| 623 | let store = self.store |
| 624 | Task.detached { store.clear() } |
| 625 | } |
| 626 | cookies = "" |
| 627 | origin = "" |
| 628 | room = "" |
| 629 | signedIn = false |
| 630 | visiting = false |
| 631 | canStart = false |
| 632 | unchecked = false |
| 633 | restoring = false |
| 634 | opening = false |
| 635 | } |
| 636 | |
| 637 | /// Opens a meeting or invitation address on the instance this app is |
| 638 | /// signed in to, with the session it holds, and joins it. The |
| 639 | /// instance's own address opens nothing and simply comes home. |
| 640 | /// |
| 641 | /// A session the instance no longer accepts is forgotten, and the |
| 642 | /// link goes through the browser as it would have with no session at |
| 643 | /// all. |
| 644 | private func open(_ address: String, offer: String? = nil) { |
| 645 | guard !opening, !starting, !joining else { return } |
| 646 | opening = true |
| 647 | error = "" |
| 648 | let origin = self.origin, cookies = self.cookies, remote = self.remote |
| 649 | let operation = callEpoch |
| 650 | Task { |
| 651 | do { |
| 652 | let answer = try await Task.detached { try remote.open(origin, cookies, address) }.value |
| 653 | guard operation == callEpoch else { return } |
| 654 | opening = false |
| 655 | let link = try JSONDecoder().decode(OpenedLink.self, from: Data(answer.utf8)) |
| 656 | if link.signedOut == true { |
| 657 | let visit = visiting |
| 658 | forget(keeping: visit) |
| 659 | begin( |
| 660 | address, visit: visit || Self.instance(of: address) != server, retry: "", |
| 661 | offer: offer) |
| 662 | return |
| 663 | } |
| 664 | // Signed in, as somebody who cannot join that meeting (2026-10-02). |
| 665 | // From the web's "Join in the app", the person in that browser is |
| 666 | // handed over for it; otherwise the person is asked whether to |
| 667 | // sign in for it. Either way it is a visit: the app's own sign-in |
| 668 | // is still the one it wakes up with. |
| 669 | if link.noAccess == true { |
| 670 | if let offer { |
| 671 | begin(address, visit: true, retry: "", offer: offer) |
| 672 | } else { |
| 673 | pendingLink = address |
| 674 | pendingNoAccess = true |
| 675 | } |
| 676 | return |
| 677 | } |
| 678 | self.address = "" |
| 679 | room = link.room |
| 680 | if !link.room.isEmpty { join() } |
| 681 | } catch { |
| 682 | guard operation == callEpoch else { return } |
| 683 | opening = false |
| 684 | self.error = error.localizedDescription |
| 685 | } |
| 686 | } |
| 687 | } |
| 688 | |
| 689 | private struct OpenedLink: Decodable { |
| 690 | let room: String |
| 691 | let signedOut: Bool? |
| 692 | let noAccess: Bool? |
| 693 | } |
| 694 | |
| 695 | /// The instance an address is on, written the way the server's origin |
| 696 | /// is — scheme, host and any port — so the two can be compared as |
| 697 | /// strings. Nil for anything that is not an address at all. |
| 698 | static func instance(of address: String) -> String? { |
| 699 | guard let url = URLComponents(string: address), let scheme = url.scheme, let host = url.host, |
| 700 | !host.isEmpty |
| 701 | else { return nil } |
| 702 | return "\(scheme)://\(host)" + (url.port.map { ":\($0)" } ?? "") |
| 703 | } |
| 704 | |
| 705 | /// Hands the server an Apple device token, so a knock reaches this |
| 706 | /// device when the app is closed. Called from the app delegate when |
| 707 | /// Apple answers, which may be long after the join that asked, and |
| 708 | /// may never happen at all. |
| 709 | /// |
| 710 | /// Silent on failure on purpose: nobody asked for this and there is |
| 711 | /// nothing they could do about it. The consequence of failing is no |
| 712 | /// push, which is what they had before push existed. |
| 713 | public func registerDevice(token: Data) { |
| 714 | guard signedIn, !origin.isEmpty, !cookies.isEmpty else { return } |
| 715 | let origin = self.origin, cookies = self.cookies |
| 716 | let hex = RemoteKnocks.hex(token) |
| 717 | let platform = RemoteKnocks.platform |
| 718 | Task.detached { |
| 719 | // gomobile renders a func returning only error as BOOL plus an |
| 720 | // NSErrorPointer. Both are dropped: see above. |
| 721 | var error: NSError? |
| 722 | BridgeRegisterDevice(origin, cookies, hex, platform, &error) |
| 723 | } |
| 724 | } |
| 725 | |
| 726 | public func join() { |
| 727 | callEpoch = UUID() |
| 728 | let operation = callEpoch |
| 729 | let origin = self.origin, room = self.room, cookies = self.cookies |
| 730 | joining = true |
| 731 | error = "" |
| 732 | Task { |
| 733 | guard operation == callEpoch else { return } |
| 734 | do { try await call.join(origin: origin, room: room, cookies: cookies) } catch { |
| 735 | guard operation == callEpoch else { return } |
| 736 | self.error = error.localizedDescription |
| 737 | call.close() |
| 738 | joining = false |
| 739 | } |
| 740 | } |
| 741 | } |
| 742 | /// Leaves the call and returns when the leave has been sent (or given |
| 743 | /// up on): for quitting, where the process is about to end. |
| 744 | public func leaveNow() async { |
| 745 | callEpoch = UUID() |
| 746 | starting = false |
| 747 | cancelSignIn() |
| 748 | await call.leave() |
| 749 | joining = false |
| 750 | } |
| 751 | public func leave() { |
| 752 | callEpoch = UUID() |
| 753 | starting = false |
| 754 | let operation = callEpoch |
| 755 | cancelSignIn() |
| 756 | Task { |
| 757 | guard operation == callEpoch else { return } |
| 758 | await call.leave() |
| 759 | guard operation == callEpoch else { return } |
| 760 | joining = false |
| 761 | // A guest, or a visitor from another server, was here for that one |
| 762 | // meeting: back to the server's home, signed in or not. |
| 763 | if visiting { endVisit() } |
| 764 | } |
| 765 | } |
| 766 | } |
| 767 | |