import Combine import Foundation @preconcurrency import CallsBridge #if os(iOS) import UIKit #else import AppKit #endif @MainActor public final class CallsModel: ObservableObject { /// The signed-in home's link field: a link someone sent, opened with /// the session in hand. @Published public var address = "" /// The Calls server this app signs in to (see CallsServer). Changed only /// through changeServer, never by a link. @Published public private(set) var server: String /// A meeting link waiting on a sign-in: one that arrived through /// `oficina-calls://join`, on another server or with nobody signed in. @Published public var pendingLink = "" /// The pending link is on the instance this app is signed in to, but /// the sign-in is somebody who cannot join that meeting — a guest /// signed in on the web, say, with the app signed in as a member of /// another team. Signing in for it is a visit: the app's own sign-in /// stays. @Published public var pendingNoAccess = false /// The browser handoff under way was started by the web's "Join in the /// app": the person confirms in the browser they came from, which is /// already showing the code, rather than in one this app opens. @Published public var handingOff = false /// The offer from the link that launched the app while the kept /// sign-in was still being checked, for when the check is done. private var pendingOffer: String? /// Signed in for one meeting only — as a guest, or on a server that is /// not this app's. Such a session is never kept, and leaving the /// meeting drops it and comes back to the server's own home. @Published public var visiting = false @Published public var error = "" @Published public var signin: URL? @Published public var authenticating = false @Published public var confirmationCode = "" private var authentication: (any Handoff)? private var authEpoch = UUID() private var callEpoch = UUID() @Published public var signedIn = false @Published public var joining = false { didSet { updateWake() } } /// The display kept awake through the call, Mac only (CallWake). let wake = CallWake() private var wakeWatch: AnyCancellable? /// A meeting is starting on the server; there is nothing to join yet. @Published public var starting = false /// A link is being opened on the instance this app is signed in to, /// with the session it already holds. @Published public var opening = false /// The sign-in kept from last time is being read and checked with its /// instance. Nothing is offered until it answers, because what is /// offered depends on the answer. @Published public var restoring = false /// The kept sign-in could not be checked — no network, or a server /// that did not answer — and is being used unchecked. It is kept, /// because a bad connection says nothing about a session, and the /// lobby offers to check again. @Published public var unchecked = false /// The server has not said this session cannot start meetings. The /// session is the person's whole session however sign-in began — a /// link names where the browser lands, not what the session may do — /// and the server's requireMember is the gate that matters. This goes /// down only when the server has answered that the session is not a /// member's, so the button is not offered to someone it would refuse. @Published public var canStart = false /// Empty while signed in with no meeting: the state a person is in /// between starting one and finishing the last. @Published public var room = "" @Published public var origin = "" public let call = Call() /// Internal rather than private so tests can hand the model a session /// without a browser; nothing outside CallsKit sees it. var cookies = "" private let store: SessionStore private let remote: SessionRemote private let defaults: UserDefaults /// Moved on by anything that makes the kept sign-in's answer stale — /// a fresh sign-in, a sign-out — so a check still in flight lands on /// nothing. private var sessionEpoch = UUID() /// A meeting link arrived while the kept sign-in was still being /// checked: the app was opened *by* the link. It is opened as soon as /// the check says the session is good, rather than asking again. private var openAfterRestore = false public convenience init( store: SessionStore = KeychainSessionStore(), defaults: UserDefaults = .standard ) { self.init(store: store, remote: .bridge, defaults: defaults) } init(store: SessionStore, remote: SessionRemote, defaults: UserDefaults = .standard) { self.store = store self.remote = remote self.defaults = defaults server = CallsServer.load(from: defaults) // A call that ends (the meeting ended, or this person was removed) // lets the display sleep even before it is left. wakeWatch = call.$state.sink { [weak self] state in self?.updateWake(phase: state?.phase) } } private func updateWake(phase: String? = nil) { #if os(macOS) wake.update(inCall: CallWake.inCall(joining: joining, phase: phase ?? call.state?.phase)) #endif } /// The server's host, for the line that names it. public var serverHost: String { CallsServer.host(of: server) } /// The host a pending link is on. public var pendingHost: String { CallsServer.host(of: Self.instance(of: pendingLink) ?? "") } /// Whether the pending link is on this app's own server. public var pendingOnServer: Bool { Self.instance(of: pendingLink) == server } /// The instance's host, for a line that says where this app is signed /// in. The scheme is dropped: it is https for everyone but a developer /// on loopback, and naming it would only make the line longer. public var host: String { URLComponents(string: origin)?.host ?? origin } public func openMeetingURL(_ url: URL) { guard !joining else { error = "Leave the current meeting before opening another." return } guard url.scheme == "oficina-calls", url.host == "join", let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let target = components.queryItems?.first(where: { $0.name == "url" })?.value, case .success(let meeting) = MeetingLink.validate(target, allowLoopback: false) else { error = "This meeting link is not valid." return } // The web's "Join in the app" (2026-10-02) adds the offer its // signed-in browser made: the way to this person's own standing in // that meeting, confirmed in that browser. An offer that is not the // shape the server makes is ignored, and the link is an ordinary one. let offer = components.queryItems?.first(where: { $0.name == "offer" })?.value .flatMap { Self.validOffer($0) ? $0 : nil } let address = meeting.absoluteString pendingNoAccess = false // The link launched the app, and the kept sign-in is still being // checked. check() opens it once the answer is in. if restoring { pendingLink = address pendingOffer = offer openAfterRestore = true return } // Already signed in to that instance: the session in hand is tried // first, so a member opening their own meeting needs no browser at // all. open() falls back to the offer, or a sign-in, if it cannot. if signedIn, Self.instance(of: address) == origin { pendingLink = "" open(address, offer: offer) return } cancelSignIn() error = "" // From the web: the person is signed in in the browser they came // from, and confirming there is the whole of it. Started rather than // offered — they asked for this meeting in the app a moment ago — and // a visit unless it is on this app's own server. if let offer { pendingLink = "" begin(address, visit: Self.instance(of: address) != server, retry: "", offer: offer) return } // Another server, or nobody signed in: that is a sign-in, offered // rather than started, and whatever session is in hand stays until a // new one replaces it. A link never changes the server setting. pendingLink = address } /// An offer is 32 random bytes, base64url without padding. nonisolated static func validOffer(_ offer: String) -> Bool { offer.count == 43 && offer.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.contains($0) && $0.isASCII || $0 == "-" || $0 == "_" } } /// The signed-out home's one button: sign in to this app's server, or — /// when a link is waiting — to the link's server, landing on its meeting. public func signIn() { let link = pendingLink guard !link.isEmpty else { begin(server, visit: false, retry: "Choose Sign in to try again.") return } begin( link, visit: pendingNoAccess || Self.instance(of: link) != server, retry: "Choose Sign in to try again.") } /// Puts the waiting link down without signing in. public func dismissPendingLink() { pendingLink = "" pendingNoAccess = false pendingOffer = nil openAfterRestore = false } /// Joins a meeting as a guest from a pasted invite link: that meeting's /// own browser sign-in, on whatever server the link names. A guest's /// session is for that meeting only — it is not kept, and it does not /// change the server setting. Nil on success, otherwise what is wrong /// with the link. @discardableResult public func joinAsGuest(_ text: String) -> MeetingLink.Problem? { switch MeetingLink.validate(text) { case .failure(let problem): return problem case .success(let link): pendingLink = "" begin( link.absoluteString, visit: true, retry: "Choose Join a call as a guest to try again.") return nil } } /// The signed-in home's link field. A link on the instance in hand opens /// with its session; anything else is a sign-in for it, kept only when /// it is on this app's server. public func connect() { let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) if signedIn, Self.instance(of: address) == origin { open(address) return } begin(address, visit: Self.instance(of: address) != server, retry: "") } /// The browser handoff for an address: the server itself, or a meeting /// link on any server. A visit's session is never kept; `retry` is /// appended to a failure so the error points at the way to try again. /// /// With an `offer` (the web's "Join in the app") the browser the person /// came from confirms it — it is already waiting for this request — so /// no browser is opened here, and once the session is in hand the /// meeting is joined, which is what they asked for. private func begin(_ address: String, visit: Bool, retry: String, offer: String? = nil) { cancelSignIn() sessionEpoch = UUID() restoring = false authenticating = true handingOff = offer != nil pendingOffer = nil error = "" let epoch = authEpoch let remote = self.remote Task { do { let auth = try await Task.detached { try remote.begin(address, offer) }.value guard epoch == authEpoch else { auth.cancel() return } authentication = auth let url = URL(string: auth.browserURL()) if offer == nil { guard let url, await openBrowser(url) else { auth.cancel() throw NSError( domain: "Calls", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not open your browser."]) } } guard epoch == authEpoch else { auth.cancel() return } confirmationCode = auth.confirmationCode() signin = url let result = try await Task.detached { try auth.wait() }.value guard epoch == authEpoch else { return } try adopt(result, visit: visit) if offer != nil, !room.isEmpty { join() } #if os(macOS) NSApplication.shared.activate(ignoringOtherApps: true) #endif } catch { guard epoch == authEpoch else { return } self.error = [error.localizedDescription, retry].filter { !$0.isEmpty }.joined( separator: " ") cancelSignIn() // The offer has gone: the meeting is still there to sign in to. if offer != nil { pendingLink = address } } } } /// Takes on the session a browser sign-in handed back. A visit's — /// a guest's, or one on a server that is not this app's — is used for /// its meeting and never kept; the server's own goes to keep(), which /// keeps it if it is a member's. func adopt(_ result: String, visit: Bool) throws { let response = try JSONDecoder().decode(SignInResult.self, from: Data(result.utf8)) cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! origin = response.origin room = response.room // Offered whether the address was a link or the instance: see // canStart. keep() takes it back if the server says otherwise. // A visit is for its one meeting, so it starts nothing. let visiting = visit || response.origin != server canStart = !visiting unchecked = false signedIn = true self.visiting = visiting address = "" pendingLink = "" pendingNoAccess = false authenticating = false handingOff = false signin = nil authentication = nil if !visiting { keep(StoredSession(origin: origin, cookies: cookies)) } } public func openBrowser(_ url: URL) async -> Bool { #if os(iOS) return await UIApplication.shared.open(url) #else return NSWorkspace.shared.open(url) #endif } public func cancelSignIn() { authEpoch = UUID() starting = false authentication?.cancel() authentication = nil authenticating = false handingOff = false confirmationCode = "" signin = nil } private struct SignInResult: Decodable { let origin: String let room: String let cookies: [Cookie] struct Cookie: Codable { let name: String let value: String } } /// Creates a meeting on the signed-in instance and joins it. The /// server decides the title, in its own zone, so a Mac in another one /// cannot name a meeting an hour it is not. /// /// The identifier is kept after the call ends rather than cleared, so /// leaving and rejoining the meeting you started is one button and not /// a second meeting. Starting again simply replaces it. public func startMeeting() { guard !starting, !joining else { return } guard canStart else { error = "This account cannot start meetings on that Calls server." return } starting = true error = "" let origin = self.origin, cookies = self.cookies let operation = callEpoch Task { do { let sqid = try await Task.detached { var error: NSError? // The device's zone, so the title the server writes is the // time here ("Call, 2 Oct 15:40"). let sqid = BridgeStartMeeting(origin, cookies, TimeZone.current.identifier, &error) if let error { throw error } return sqid }.value // A leave or a second start while the server was answering: the // meeting exists and is simply not the one this app is on. It is // left alone rather than ended, because ending someone else's // newer meeting is the worse mistake. guard operation == callEpoch else { return } starting = false room = sqid join() } catch { guard operation == callEpoch else { return } self.error = error.localizedDescription starting = false } } } // MARK: The kept sign-in /// Reads the sign-in kept from last time and checks it with its /// instance. Called once, when the app's window appears; a model that /// is never asked — a snapshot, a test — never touches the Keychain. public func restore() { guard !signedIn, !authenticating, !restoring else { return } restoring = true let epoch = sessionEpoch let store = self.store Task { let kept = await Task.detached { store.load() }.value guard epoch == sessionEpoch else { return } guard let kept else { restoring = false openAfterRestore = false continueOffer() return } // Kept for a server that is no longer this app's — the setting was // changed outside the app. It is signed out, as changing it in // Settings would have done. guard kept.origin == server else { restoring = false openAfterRestore = false let remote = self.remote Task.detached { store.clear() remote.signOut(kept.origin, kept.cookies) } continueOffer() return } origin = kept.origin cookies = kept.cookies check(kept, epoch: epoch) } } /// Asks again after a check that could not be made. public func recheck() { guard signedIn, unchecked, !restoring else { return } restoring = true error = "" check(StoredSession(origin: origin, cookies: cookies), epoch: sessionEpoch) } private func check(_ kept: StoredSession, epoch: UUID) { let remote = self.remote Task { let answer = await Task.detached { Result { try remote.check(kept.origin, kept.cookies) } } .value guard epoch == sessionEpoch else { return } restoring = false switch answer { case .success(SessionRemote.member): signedIn = true visiting = false unchecked = false canStart = true error = "" if openAfterRestore, Self.instance(of: pendingLink) == origin { let link = pendingLink, offer = pendingOffer pendingLink = "" pendingOffer = nil open(link, offer: offer) } else if openAfterRestore { continueOffer() } case .success(SessionRemote.signedOut): // The instance says the session has gone: thirty days passed, or // it was signed out elsewhere. Forget it; signing in again is the // home's one button. let host = self.host let offered = openAfterRestore forget() error = "Your sign-in to \(host) has ended. Choose Sign in to continue." if offered { continueOffer() } case .success: // Live, but no longer a member's: only a member's session is // kept, so this one is not either. let offered = openAfterRestore forget() if offered { continueOffer() } case .failure(let failure): // Not an answer about the session. Keep it and use it; every // request it makes is gated on the server regardless. signedIn = true visiting = false unchecked = true canStart = true error = "Could not check your sign-in to \(host): \(failure.localizedDescription)" // The link that launched the app is still tried with it: open() // falls back to a browser sign-in if the server says it has ended. if openAfterRestore, Self.instance(of: pendingLink) == origin { let link = pendingLink, offer = pendingOffer pendingLink = "" pendingOffer = nil open(link, offer: offer) } else if openAfterRestore { continueOffer() } } openAfterRestore = false } } /// A link from the web's "Join in the app" launched the app, and there /// is no sign-in it can use: the offer is taken up now, as it would /// have been had the app already been running. private func continueOffer() { guard let offer = pendingOffer, !pendingLink.isEmpty else { return } let link = pendingLink pendingOffer = nil pendingLink = "" begin(link, visit: Self.instance(of: link) != server, retry: "", offer: offer) } /// Keeps a fresh sign-in if it is a member's. Only a member's session /// is worth waking up holding: a guest's, or an invited address's, is /// good for the meeting it came in through and nothing else, and is /// signed in again from the next link like it always was. /// /// A check that cannot be made keeps nothing. The cost is one more /// browser sign-in next launch, and the alternative is keeping a /// session nobody has looked at. func keep(_ session: StoredSession) { let epoch = sessionEpoch let remote = self.remote, store = self.store Task { let answer = await Task.detached { try? remote.check(session.origin, session.cookies) }.value guard epoch == sessionEpoch, cookies == session.cookies else { return } switch answer { case SessionRemote.member?: // Only this app's server's session is kept: the Keychain holds // one sign-in, and it is the server's. guard session.origin == server else { return } await Task.detached { store.save(session) }.value case SessionRemote.participant?: canStart = false default: break } } } /// Signs this app out: the kept copy is forgotten at once, and the /// instance is asked to revoke the session so it is dead and not just /// dropped. If the instance cannot be reached the session lapses at /// its thirty days, and nothing on this device can use it before then. /// /// Signing out of a visit ends only the visit: the server's own kept /// sign-in, if there is one, is untouched and comes back. public func signOut() { guard !joining else { return } if visiting { endVisit() return } let origin = self.origin, cookies = self.cookies let remote = self.remote forget() address = "" error = "" guard !origin.isEmpty, !cookies.isEmpty else { return } Task.detached { remote.signOut(origin, cookies) } } /// Drops a visit's session — revoked, as a sign-out would — and goes /// back to the server's home: its kept sign-in if there is one, /// otherwise signed out. private func endVisit() { let origin = self.origin, cookies = self.cookies let remote = self.remote forget(keeping: true) address = "" if !origin.isEmpty, !cookies.isEmpty { Task.detached { remote.signOut(origin, cookies) } } restore() } /// Whether changing the server would sign this app out. public var changingServerSignsOut: Bool { signedIn || restoring } /// Moves the app to another Calls server. Nil on success, otherwise /// what is wrong with the address. /// /// A session for the old server is signed out, as Sign out does: the /// Keychain copy forgotten, the instance asked to revoke it. The person /// has confirmed that before this is called (changingServerSignsOut). @discardableResult public func changeServer(to text: String) -> CallsServer.Problem? { let origin: String switch CallsServer.validate(text) { case .failure(let problem): return problem case .success(let valid): origin = valid } guard !joining else { error = "Leave the current meeting before changing the Calls server." return nil } CallsServer.save(origin, to: defaults) guard origin != server else { return nil } server = origin let held = cookies.isEmpty ? nil : StoredSession(origin: self.origin, cookies: cookies) let store = self.store, remote = self.remote forget(keeping: true) address = "" error = "" Task.detached { let kept = store.load() store.clear() for session in Set([kept, held].compactMap { $0 }.map { [$0.origin, $0.cookies] }) { remote.signOut(session[0], session[1]) } } return nil } /// Everything a sign-in left behind, in memory and — unless `keeping`, /// for a visit whose session was never kept — in the Keychain. private func forget(keeping: Bool = false) { openAfterRestore = false sessionEpoch = UUID() callEpoch = UUID() cancelSignIn() if !keeping { let store = self.store Task.detached { store.clear() } } cookies = "" origin = "" room = "" signedIn = false visiting = false canStart = false unchecked = false restoring = false opening = false } /// Opens a meeting or invitation address on the instance this app is /// signed in to, with the session it holds, and joins it. The /// instance's own address opens nothing and simply comes home. /// /// A session the instance no longer accepts is forgotten, and the /// link goes through the browser as it would have with no session at /// all. private func open(_ address: String, offer: String? = nil) { guard !opening, !starting, !joining else { return } opening = true error = "" let origin = self.origin, cookies = self.cookies, remote = self.remote let operation = callEpoch Task { do { let answer = try await Task.detached { try remote.open(origin, cookies, address) }.value guard operation == callEpoch else { return } opening = false let link = try JSONDecoder().decode(OpenedLink.self, from: Data(answer.utf8)) if link.signedOut == true { let visit = visiting forget(keeping: visit) begin( address, visit: visit || Self.instance(of: address) != server, retry: "", offer: offer) return } // Signed in, as somebody who cannot join that meeting (2026-10-02). // From the web's "Join in the app", the person in that browser is // handed over for it; otherwise the person is asked whether to // sign in for it. Either way it is a visit: the app's own sign-in // is still the one it wakes up with. if link.noAccess == true { if let offer { begin(address, visit: true, retry: "", offer: offer) } else { pendingLink = address pendingNoAccess = true } return } self.address = "" room = link.room if !link.room.isEmpty { join() } } catch { guard operation == callEpoch else { return } opening = false self.error = error.localizedDescription } } } private struct OpenedLink: Decodable { let room: String let signedOut: Bool? let noAccess: Bool? } /// The instance an address is on, written the way the server's origin /// is — scheme, host and any port — so the two can be compared as /// strings. Nil for anything that is not an address at all. static func instance(of address: String) -> String? { guard let url = URLComponents(string: address), let scheme = url.scheme, let host = url.host, !host.isEmpty else { return nil } return "\(scheme)://\(host)" + (url.port.map { ":\($0)" } ?? "") } /// Hands the server an Apple device token, so a knock reaches this /// device when the app is closed. Called from the app delegate when /// Apple answers, which may be long after the join that asked, and /// may never happen at all. /// /// Silent on failure on purpose: nobody asked for this and there is /// nothing they could do about it. The consequence of failing is no /// push, which is what they had before push existed. public func registerDevice(token: Data) { guard signedIn, !origin.isEmpty, !cookies.isEmpty else { return } let origin = self.origin, cookies = self.cookies let hex = RemoteKnocks.hex(token) let platform = RemoteKnocks.platform Task.detached { // gomobile renders a func returning only error as BOOL plus an // NSErrorPointer. Both are dropped: see above. var error: NSError? BridgeRegisterDevice(origin, cookies, hex, platform, &error) } } public func join() { callEpoch = UUID() let operation = callEpoch let origin = self.origin, room = self.room, cookies = self.cookies joining = true error = "" Task { guard operation == callEpoch else { return } do { try await call.join(origin: origin, room: room, cookies: cookies) } catch { guard operation == callEpoch else { return } self.error = error.localizedDescription call.close() joining = false } } } /// Leaves the call and returns when the leave has been sent (or given /// up on): for quitting, where the process is about to end. public func leaveNow() async { callEpoch = UUID() starting = false cancelSignIn() await call.leave() joining = false } public func leave() { callEpoch = UUID() starting = false let operation = callEpoch cancelSignIn() Task { guard operation == callEpoch else { return } await call.leave() guard operation == callEpoch else { return } joining = false // A guest, or a visitor from another server, was here for that one // meeting: back to the server's home, signed in or not. if visiting { endVisit() } } } }