oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import 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