Keep the native sign-in, and land home after it however it began
"Why do I have to sign in via the web every time?" Because the session lived only in memory. A member's session now goes into the Keychain — a generic password, this device only, readable while unlocked — and the next launch checks it with the instance before offering anything. Ended (thirty days, or revoked) forgets it and leaves the instance address in the field; no network keeps it, uses it, and offers to check again. Only a member's session is kept: a guest's or an invited address's is good for the meeting it came in through. Sign out forgets the copy at once and asks the instance to revoke the session. On a Mac the this-device-only keychain needs an entitlement the ad hoc development build does not carry (-34018, confirmed here), so the item falls back to the login keychain there. "Can I start a call from within the app?" It existed, behind canStart, which was true only for someone who signed in with the bare instance address. The session is the person's whole session whichever way the browser landed, and requireMember is the real gate, so signed in is now home for everyone: Start meeting, Join meeting when there is a meeting in hand, a field to open a link, and Sign out. canStart goes down only when the server has said the session is not a member's. A link on the signed-in instance — pasted, or as oficina-calls://join — opens with the session and joins; a link that launched the app waits for the check and then opens. Another instance is that instance's sign-in, as before. Leaving a call comes back home. The Keychain sits behind SessionStore and the three requests behind SessionRemote, so the tests use neither. The iPhone UI tests launch with -CallsForgetSignIn YES, because the simulator's Keychain outlives reinstalls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+782
−69
native/README.md+43 −14native/apple/Sources/CallsInterop/LobbySnapshots.swift+8 −1native/apple/Sources/CallsKit/CallsLobby.swift+55 −24native/apple/Sources/CallsKit/CallsModel.swift+248 −12native/apple/Sources/CallsKit/SessionStore.swift+146 −0native/apple/Sources/CallsMac/CallsMac.swift+4 −1native/apple/Tests/CallsKitTests/KeptSignInTests.swift+238 −0native/apple/Tests/CallsKitTests/MeetingLinkTests.swift+2 −2native/apple/Tests/CallsKitTests/StartMeetingTests.swift+16 −11native/ios/README.md+8 −3native/ios/Sources/CallsPhone.swift+12 −1native/ios/UITests/CallsPhoneUITests.swift+2 −0
diff --git a/native/README.md b/native/README.md| index 5f6a204..c3e468b 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -11,19 +11,47 @@ make native-mac |
| open native/build/Calls.app |
| ``` |
| -Paste a meeting or invitation URL — or, to start a meeting of your own, your |
| -instance address alone (`https://calls.oficina.works`). Calls opens your default |
| -browser for the existing Home/Calls sign-in. Open the emailed link normally, |
| -compare the code in the browser with the app, and confirm **Sign in to Calls |
| -app**. The app continues automatically; choose **Join meeting**, or **Start |
| -meeting** when you signed in without a link, and allow camera/microphone access. |
| - |
| -A link signs you in to that one meeting; an address signs you in to the |
| -instance, which is what **Start meeting** needs. Starting goes through |
| -`POST /native/meetings/start` — the same `store.createMeeting`, behind the same |
| -`requireMember`, as the web list's one-click button — and answers only the |
| -meeting's identifier. The invitation is not part of the answer: a link is a |
| -credential, and the room's own **Share** is where one is minted. |
| +Paste a meeting or invitation URL — or your instance address alone |
| +(`https://calls.oficina.works`). Calls opens your default browser for the |
| +existing Home/Calls sign-in. Open the emailed link normally, compare the code in |
| +the browser with the app, and confirm **Sign in to Calls app**. The app |
| +continues automatically. |
| + |
| +Once signed in — however sign-in began — the app is home: **Start meeting**, |
| +**Join meeting** when there is a meeting in hand (the link you signed in from, |
| +or the call you just left), a field to open a link someone sent you, and |
| +**Sign out**. A link on the instance you are signed in to opens with the session |
| +the app already holds, pasted or arriving as `oficina-calls://join?url=…`, and |
| +joins straight away; a link on another instance is that instance's sign-in, as |
| +before. Leaving a call comes back home. |
| + |
| +The session is the person's whole session whether the browser landed on a link |
| +or on the meeting list — the link only decides where it lands. Who may start a |
| +meeting is `requireMember`'s decision on the server, and a refusal is shown as |
| +the server gives it. Starting goes through `POST /native/meetings/start` — the |
| +same `store.createMeeting`, behind the same `requireMember`, as the web list's |
| +one-click button — and answers only the meeting's identifier. The invitation is |
| +not part of the answer: a link is a credential, and the room's own **Share** is |
| +where one is minted. |
| + |
| +**The sign-in is kept.** A member's session goes into the Keychain — a generic |
| +password, this device only, readable while unlocked — and the next launch |
| +checks it with the instance before offering anything: one `GET /settings`, |
| +member-only, read by status alone. A member's page means signed in; the |
| +sign-in redirect means the session has ended (thirty days, or revoked), so it |
| +is forgotten and the instance address is left in the field; anything else — |
| +no network, a 5xx — keeps it, uses it, and offers **Check sign-in again**. Only |
| +a member's session is kept: a guest's, or an invited non-member's, is good for |
| +the meeting it came in through and is signed in again from the next link. |
| +**Sign out** forgets the Keychain copy at once and asks the instance to revoke |
| +the session with the web's own `POST /signout`; if that cannot be reached, the |
| +session lapses at its thirty days. |
| + |
| +On a Mac, the this-device-only keychain needs a keychain-access-groups |
| +entitlement that the ad hoc signed `make native-mac` build does not carry. |
| +There the item falls back to the login keychain, protected by the login |
| +password and the app's code signature; because an ad hoc signature changes on |
| +every rebuild, macOS may ask once per build whether Calls may read it. |
| Scheduling a meeting for later, ending one, and replacing invitations stay on |
| the web for this milestone. |
| @@ -100,7 +128,8 @@ media. The shared Go core waits for a five-minute, one-use authorization bound |
| to a proof secret held only by the app. Browser approval creates a separate, |
| ordinary framework session, preserving the existing identity method and Home |
| assertion authentication time. No browser cookie is copied, and no new bearer |
| -authentication type is introduced. Credentials stay in memory for this milestone. |
| +authentication type is introduced. A member's session is kept in the Keychain, |
| +as described above. |
| **The server must include this branch's native authorization endpoints.** An |
| older server cannot complete native sign-in; the app reports that limitation. |
diff --git a/native/apple/Sources/CallsInterop/LobbySnapshots.swift b/native/apple/Sources/CallsInterop/LobbySnapshots.swift| index 77e1955..2a19b4f 100644 |
| --- a/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| @@ -9,15 +9,22 @@ import SwiftUI |
| let folder = URL(fileURLWithPath: directory) |
| try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| for scheme in [ColorScheme.light, .dark] { |
| - for state in ["welcome", "signin", "ready", "error"] { |
| + for state in ["welcome", "signin", "home", "ready", "error"] { |
| let model = CallsModel() |
| if state == "signin" { |
| model.authenticating = true |
| model.confirmationCode = "A1B2-C3D4" |
| model.signin = URL(string: "https://example.invalid") |
| } |
| + if state == "home" { |
| + model.signedIn = true |
| + model.canStart = true |
| + model.origin = "https://calls.oficina.works" |
| + } |
| if state == "ready" { |
| model.signedIn = true |
| + model.canStart = true |
| + model.origin = "https://calls.oficina.works" |
| model.room = "team-catchup" |
| } |
| if state == "error" { model.error = "Enter a full meeting or invitation link." } |
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift| index 67d272d..0381acb 100644 |
| --- a/native/apple/Sources/CallsKit/CallsLobby.swift |
| +++ b/native/apple/Sources/CallsKit/CallsLobby.swift |
| @@ -20,7 +20,9 @@ import SwiftUI |
| Text(subheading).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) |
| } |
| VStack(alignment: .leading, spacing: 20) { |
| - if model.authenticating { |
| + if model.restoring { |
| + ProgressView("Checking your sign-in") |
| + } else if model.authenticating { |
| ProgressView("Waiting for browser confirmation") |
| if !model.confirmationCode.isEmpty { |
| VStack(alignment: .leading, spacing: 10) { |
| @@ -38,18 +40,17 @@ import SwiftUI |
| } |
| Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered) |
| } else if model.signedIn { |
| - if model.room.isEmpty { |
| - Label("Signed in to \(model.host)", systemImage: "person.crop.circle.badge.checkmark") |
| - .font(.headline).textSelection(.enabled) |
| - } else { |
| + Label("Signed in to \(model.host)", systemImage: "person.crop.circle.badge.checkmark") |
| + .font(.headline).textSelection(.enabled) |
| + if !model.room.isEmpty { |
| Label("Meeting \(model.room)", systemImage: "video") |
| .font(.headline).textSelection(.enabled) |
| } |
| Label("Your camera and microphone will be requested when you join.", systemImage: "mic") |
| .font(.callout).foregroundStyle(.secondary) |
| - // The meeting in hand comes first when there is one. Starting |
| - // is the primary act only for the person who arrived without |
| - // a link, which is the whole case it was added for. |
| + // The meeting in hand comes first when there is one — the link |
| + // someone signed in from, or the call they just left. Otherwise |
| + // starting one is the primary act, however sign-in began. |
| if !model.room.isEmpty { |
| Button("Join meeting", systemImage: "video.fill", action: model.join) |
| .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| @@ -57,26 +58,23 @@ import SwiftUI |
| } |
| if model.canStart { startButton } |
| if model.starting { ProgressView("Starting a meeting") } |
| - Button("Choose another meeting") { model.signedIn = false } |
| - .buttonStyle(.bordered) |
| + if model.opening { ProgressView("Opening the meeting") } |
| + // A link from someone else opens here with the same session: |
| + // on this instance it joins straight away, and on another it is |
| + // that instance's sign-in. |
| + linkField(submit: "Open link", systemImage: "link", prominent: false) |
| + if model.unchecked { |
| + Button("Check sign-in again", systemImage: "arrow.clockwise", action: model.recheck) |
| + .buttonStyle(.bordered) |
| + } |
| + Button("Sign out", systemImage: "rectangle.portrait.and.arrow.right", action: model.signOut) |
| + .buttonStyle(.bordered).accessibilityIdentifier("signOut") |
| #if os(iOS) |
| Text("Keep Calls open during your call. Switching to another app ends the call.") |
| .font(.footnote).foregroundStyle(.secondary) |
| #endif |
| } else { |
| - VStack(alignment: .leading, spacing: 10) { |
| - Text("Meeting link").font(.headline) |
| - TextField("Meeting link, or your Calls address", text: $model.address) |
| - .textFieldStyle(.roundedBorder) |
| - .accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink") |
| - #if os(iOS) |
| - .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() |
| - #endif |
| - .onSubmit(model.connect) |
| - } |
| - Button("Continue", systemImage: "arrow.right", action: model.connect) |
| - .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| - .disabled(model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| + linkField(submit: "Continue", systemImage: "arrow.right", prominent: true) |
| Text( |
| "Use the meeting or invitation link shared with you — or your own Calls address, to start a meeting of your own." |
| ) |
| @@ -103,6 +101,35 @@ import SwiftUI |
| .tint(CallsAppearance.accent) |
| } |
| + /// The one address field, signed in or not. Signed out it is the way |
| + /// in; signed in it is how a link someone sent is opened without |
| + /// signing in again, so it stays second to the buttons above it. |
| + @ViewBuilder private func linkField( |
| + submit: LocalizedStringKey, systemImage: String, prominent: Bool |
| + ) -> some View { |
| + VStack(alignment: .leading, spacing: 10) { |
| + Text("Meeting link").font(.headline) |
| + TextField("Meeting link, or your Calls address", text: $model.address) |
| + .textFieldStyle(.roundedBorder) |
| + .accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink") |
| + #if os(iOS) |
| + .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() |
| + #endif |
| + .onSubmit(model.connect) |
| + } |
| + let empty = model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| + // Two branches for the reason startButton has two: buttonStyle's type |
| + // differs between them. Only the way in takes the default action; |
| + // signed in, that belongs to the meeting buttons above. |
| + if prominent { |
| + Button(submit, systemImage: systemImage, action: model.connect) |
| + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction).disabled(empty) |
| + } else { |
| + Button(submit, systemImage: systemImage, action: model.connect) |
| + .buttonStyle(.bordered).disabled(empty || model.opening) |
| + } |
| + } |
| + |
| // Lifted out of the body because the type checker will not carry this |
| // many conditionals inside a ViewBuilder: the whole expression times |
| // out rather than failing on one line, which reads as a compiler |
| @@ -125,18 +152,22 @@ import SwiftUI |
| } |
| private var heading: LocalizedStringKey { |
| + if model.restoring { return "Welcome back." } |
| if model.authenticating { return "One more step." } |
| if model.signedIn { return model.room.isEmpty ? "Ready when you are" : "Ready to join" } |
| return "Meet face to face." |
| } |
| private var subheading: LocalizedStringKey { |
| + if model.restoring { return "Checking that you’re still signed in." } |
| if model.authenticating { |
| return |
| "Continue in your browser, then confirm that the code matches. We’ll continue here automatically." |
| } |
| if model.signedIn { |
| - return model.room.isEmpty ? "Start a meeting and share the link from inside it." : "Join when you’re ready." |
| + return model.room.isEmpty |
| + ? "Start a meeting and share the link from inside it, or open a link you were sent." |
| + : "Join when you’re ready." |
| } |
| return "A place for your team to come together." |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift| index c1fb867..6aa43ea 100644 |
| --- a/native/apple/Sources/CallsKit/CallsModel.swift |
| +++ b/native/apple/Sources/CallsKit/CallsModel.swift |
| @@ -21,18 +21,52 @@ import Foundation |
| @Published public var joining = false |
| /// A meeting is starting on the server; there is nothing to join yet. |
| @Published public var starting = false |
| - /// This session may create meetings — it signed in to the instance |
| - /// rather than to one meeting, and the server let it. Someone who |
| - /// followed a link signs in to that meeting and nothing else, so the |
| - /// button is theirs only once they have signed in without one. |
| + /// 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() |
| - private var cookies = "" |
| - public init() {} |
| + /// 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 |
| + /// 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()) { |
| + self.init(store: store, remote: .bridge) |
| + } |
| + |
| + init(store: SessionStore, remote: SessionRemote) { |
| + self.store = store |
| + self.remote = remote |
| + } |
| /// 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 |
| @@ -61,6 +95,22 @@ import Foundation |
| error = "This meeting link is not valid." |
| return |
| } |
| + // The link launched the app, and the kept sign-in is still being |
| + // checked. check() opens it once the answer is in. |
| + if restoring { |
| + address = meeting.absoluteString |
| + openAfterRestore = true |
| + return |
| + } |
| + // Already signed in to that instance: the session in hand is the |
| + // person's own, so the link opens with it and no browser is involved. |
| + if signedIn, Self.instance(of: meeting.absoluteString) == origin { |
| + address = meeting.absoluteString |
| + open(address) |
| + return |
| + } |
| + // Another instance, or nobody signed in: that is a sign-in, and the |
| + // kept one is left alone until a new one replaces it. |
| cancelSignIn() |
| signedIn = false |
| canStart = false |
| @@ -71,11 +121,17 @@ import Foundation |
| } |
| public func connect() { |
| + let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) |
| + if signedIn, Self.instance(of: address) == origin { |
| + open(address) |
| + return |
| + } |
| cancelSignIn() |
| + sessionEpoch = UUID() |
| + restoring = false |
| authenticating = true |
| error = "" |
| let epoch = authEpoch |
| - let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) |
| Task { |
| do { |
| let auth = try await Task.detached { |
| @@ -114,17 +170,19 @@ import Foundation |
| cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! |
| origin = response.origin |
| room = response.room |
| - // No meeting came back, so the address was the instance itself |
| - // and this is the person who has no link because the meeting |
| - // does not exist yet. That is exactly who may start one. |
| - canStart = response.room.isEmpty |
| + // Offered whether the address was a link or the instance: see |
| + // canStart. keep() takes it back if the server says otherwise. |
| + canStart = true |
| + unchecked = false |
| signedIn = true |
| + self.address = "" |
| authenticating = false |
| signin = nil |
| authentication = nil |
| #if os(macOS) |
| NSApplication.shared.activate(ignoringOtherApps: true) |
| #endif |
| + keep(StoredSession(origin: origin, cookies: cookies)) |
| } catch { |
| guard epoch == authEpoch else { return } |
| self.error = error.localizedDescription |
| @@ -167,7 +225,7 @@ import Foundation |
| public func startMeeting() { |
| guard !starting, !joining else { return } |
| guard canStart else { |
| - error = "Sign in with your Calls address to start a meeting." |
| + error = "This account cannot start meetings on that Calls server." |
| return |
| } |
| starting = true |
| @@ -197,6 +255,184 @@ import Foundation |
| } |
| } |
| } |
| + // 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 |
| + 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 |
| + unchecked = false |
| + canStart = true |
| + error = "" |
| + if openAfterRestore, Self.instance(of: address) == origin { open(address) } |
| + case .success(SessionRemote.signedOut): |
| + // The instance says the session has gone: thirty days passed, or |
| + // it was signed out elsewhere. Forget it, and leave the instance |
| + // in the field so signing in again is one button. |
| + let host = self.host |
| + forget() |
| + if !openAfterRestore { address = kept.origin } |
| + error = "Your sign-in to \(host) has ended. Sign in again to continue." |
| + case .success: |
| + // Live, but no longer a member's: only a member's session is |
| + // kept, so this one is not either. |
| + forget() |
| + 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 |
| + unchecked = true |
| + canStart = true |
| + error = "Could not check your sign-in to \(host): \(failure.localizedDescription)" |
| + } |
| + openAfterRestore = false |
| + } |
| + } |
| + |
| + /// 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?: |
| + 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. |
| + public func signOut() { |
| + guard !joining else { 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) } |
| + } |
| + |
| + /// Everything a sign-in left behind, in memory and in the Keychain. |
| + private func forget() { |
| + sessionEpoch = UUID() |
| + callEpoch = UUID() |
| + cancelSignIn() |
| + let store = self.store |
| + Task.detached { store.clear() } |
| + cookies = "" |
| + origin = "" |
| + room = "" |
| + signedIn = 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) { |
| + 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 { |
| + forget() |
| + self.address = address |
| + connect() |
| + 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? |
| + } |
| + |
| + /// 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 |
diff --git a/native/apple/Sources/CallsKit/SessionStore.swift b/native/apple/Sources/CallsKit/SessionStore.swift| new file mode 100644 |
| index 0000000..0fa0fe9 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/SessionStore.swift |
| @@ -0,0 +1,146 @@ |
| +@preconcurrency import CallsBridge |
| +import Foundation |
| +import Security |
| + |
| +/// The sign-in the app keeps between launches: the instance, and the |
| +/// session cookie the browser handoff minted for it, in the JSON shape |
| +/// the bridge already reads. It is the whole of what the app needs to |
| +/// be signed in again, and so it is a credential and is kept like one. |
| +public struct StoredSession: Codable, Equatable, Sendable { |
| + public let origin: String |
| + public let cookies: String |
| + |
| + public init(origin: String, cookies: String) { |
| + self.origin = origin |
| + self.cookies = cookies |
| + } |
| +} |
| + |
| +/// Where the kept sign-in lives. A protocol so the model can be tested |
| +/// without touching the Keychain of whoever runs the tests. |
| +/// |
| +/// One sign-in at a time: saving replaces whatever was there. The app |
| +/// signs in to one instance at a time too, and a second slot would be a |
| +/// credential nobody remembers is there. |
| +public protocol SessionStore: Sendable { |
| + func load() -> StoredSession? |
| + func save(_ session: StoredSession) |
| + func clear() |
| +} |
| + |
| +/// The Keychain, as a generic password readable only on this device and |
| +/// only while it is unlocked — the session never reaches a backup or |
| +/// another device, and a phone left locked in a drawer does not hand it |
| +/// to anything running in the background, which this app never does. |
| +/// |
| +/// Failures are silent on purpose. A sign-in that could not be kept is |
| +/// one the person makes again next launch, which is exactly where every |
| +/// launch was before this existed. |
| +public struct KeychainSessionStore: SessionStore { |
| + private let service: String |
| + private let account = "session" |
| + |
| + public init(service: String = "net.amadan.oficina.calls") { |
| + self.service = service |
| + } |
| + |
| + public func load() -> StoredSession? { |
| + for keychain in Self.keychains { |
| + var query = base(keychain) |
| + query[kSecReturnData as String] = true |
| + query[kSecMatchLimit as String] = kSecMatchLimitOne |
| + var result: CFTypeRef? |
| + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, |
| + let data = result as? Data |
| + else { continue } |
| + return try? JSONDecoder().decode(StoredSession.self, from: data) |
| + } |
| + return nil |
| + } |
| + |
| + public func save(_ session: StoredSession) { |
| + guard let data = try? JSONEncoder().encode(session) else { return } |
| + clear() |
| + for keychain in Self.keychains { |
| + var item = base(keychain) |
| + item[kSecValueData as String] = data |
| + item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
| + // The first keychain that takes it is the one it lives in. On a Mac |
| + // that is the data protection keychain when the build is entitled |
| + // to it, and the login keychain when it is not. |
| + if SecItemAdd(item as CFDictionary, nil) == errSecSuccess { return } |
| + } |
| + } |
| + |
| + public func clear() { |
| + for keychain in Self.keychains { |
| + SecItemDelete(base(keychain) as CFDictionary) |
| + } |
| + } |
| + |
| + private enum Keychain { case dataProtection, legacy } |
| + |
| + /// iPhone has one keychain. A Mac has two, and the one that honours |
| + /// this-device-only needs a keychain-access-groups entitlement, which |
| + /// an ad hoc signed development build does not carry: there the add |
| + /// fails with errSecMissingEntitlement and the item goes to the login |
| + /// keychain instead, protected by the login password and the app's |
| + /// code signature rather than by the device. |
| + private static var keychains: [Keychain] { |
| + #if os(macOS) |
| + return [.dataProtection, .legacy] |
| + #else |
| + return [.dataProtection] |
| + #endif |
| + } |
| + |
| + private func base(_ keychain: Keychain) -> [String: Any] { |
| + var query: [String: Any] = [ |
| + kSecClass as String: kSecClassGenericPassword, |
| + kSecAttrService as String: service, |
| + kSecAttrAccount as String: account, |
| + ] |
| + #if os(macOS) |
| + query[kSecUseDataProtectionKeychain as String] = keychain == .dataProtection |
| + #endif |
| + return query |
| + } |
| +} |
| + |
| +/// The three requests the model makes about a session it holds, as |
| +/// closures over the bridge so tests can answer them without a server. |
| +/// Each runs off the main actor: they are network calls. |
| +struct SessionRemote: Sendable { |
| + /// "member", "participant" or "signed-out"; throws only when the |
| + /// instance could not be asked. See bridge.CheckSession. |
| + var check: @Sendable (_ origin: String, _ cookies: String) throws -> String |
| + /// JSON: {"room": ...} or {"signedOut": true}. See bridge.OpenLink. |
| + var open: @Sendable (_ origin: String, _ cookies: String, _ address: String) throws -> String |
| + /// Best effort; see bridge.SignOut. |
| + var signOut: @Sendable (_ origin: String, _ cookies: String) -> Void |
| + |
| + static let member = "member" |
| + static let participant = "participant" |
| + static let signedOut = "signed-out" |
| + |
| + static let bridge = SessionRemote( |
| + check: { origin, cookies in |
| + var error: NSError? |
| + let standing = BridgeCheckSession(origin, cookies, &error) |
| + if let error { throw error } |
| + return standing |
| + }, |
| + open: { origin, cookies, address in |
| + var error: NSError? |
| + let answer = BridgeOpenLink(origin, cookies, address, &error) |
| + if let error { throw error } |
| + return answer |
| + }, |
| + signOut: { origin, cookies in |
| + // gomobile renders a func returning only error as BOOL plus an |
| + // NSErrorPointer. Both are dropped: the app is signed out whatever |
| + // the instance answers. |
| + var error: NSError? |
| + BridgeSignOut(origin, cookies, &error) |
| + }) |
| +} |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 1f4daf9..620ace7 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -17,7 +17,10 @@ import WebRTC |
| Window("Calls", id: "main") { |
| ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| .onOpenURL { model.openMeetingURL($0) } |
| - .onAppear { CallsAppDelegate.model = model } |
| + .onAppear { |
| + CallsAppDelegate.model = model |
| + model.restore() |
| + } |
| } |
| .defaultSize(width: 1000, height: 760) |
| .commands { |
diff --git a/native/apple/Tests/CallsKitTests/KeptSignInTests.swift b/native/apple/Tests/CallsKitTests/KeptSignInTests.swift| new file mode 100644 |
| index 0000000..7d7c0ff |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/KeptSignInTests.swift |
| @@ -0,0 +1,238 @@ |
| +import Foundation |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// The Keychain's stand-in: the same three calls, held in memory, and |
| +/// safe to reach from the detached tasks the model uses for the real one. |
| +final class MemorySessionStore: SessionStore, @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var kept: StoredSession? |
| + |
| + init(_ kept: StoredSession? = nil) { self.kept = kept } |
| + |
| + var session: StoredSession? { lock.withLock { kept } } |
| + func load() -> StoredSession? { lock.withLock { kept } } |
| + func save(_ session: StoredSession) { lock.withLock { kept = session } } |
| + func clear() { lock.withLock { kept = nil } } |
| +} |
| + |
| +/// What the server said, scripted, and what the model asked it. |
| +final class ScriptedRemote: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var checks: [Result<String, Error>] |
| + private var opens: [String] |
| + private(set) var asked: [String] = [] |
| + |
| + init(checks: [Result<String, Error>] = [], opens: [String] = []) { |
| + self.checks = checks |
| + self.opens = opens |
| + } |
| + |
| + var requests: [String] { lock.withLock { asked } } |
| + |
| + var remote: SessionRemote { |
| + SessionRemote( |
| + check: { origin, cookies in |
| + try self.lock.withLock { |
| + self.asked.append("check \(origin) \(cookies)") |
| + return try self.checks.removeFirst().get() |
| + } |
| + }, |
| + open: { origin, _, address in |
| + self.lock.withLock { |
| + self.asked.append("open \(origin) \(address)") |
| + return self.opens.removeFirst() |
| + } |
| + }, |
| + signOut: { origin, cookies in |
| + self.lock.withLock { self.asked.append("signout \(origin) \(cookies)") } |
| + }) |
| + } |
| +} |
| + |
| +private let kept = StoredSession( |
| + origin: "https://calls.oficina.works", |
| + cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) |
| + |
| +private struct Unreachable: LocalizedError { |
| + var errorDescription: String? { "cannot reach Calls" } |
| +} |
| + |
| +/// The model's work here happens in tasks it starts itself; this waits |
| +/// for the state they leave rather than for the tasks, which are not |
| +/// the model's to hand out. |
| +@MainActor private func settle(_ done: @MainActor () -> Bool) async { |
| + let deadline = Date().addingTimeInterval(5) |
| + while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| +} |
| + |
| +final class KeptSignInTests: XCTestCase { |
| + @MainActor func testNothingKeptIsTheSignInScreenAndAsksNobody() async { |
| + let server = ScriptedRemote() |
| + let model = CallsModel(store: MemorySessionStore(), remote: server.remote) |
| + model.restore() |
| + await settle { !model.restoring } |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertTrue(server.requests.isEmpty) |
| + } |
| + |
| + @MainActor func testAKeptMemberSessionLandsHomeWithStartOffered() async { |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) |
| + model.restore() |
| + XCTAssertTrue(model.restoring, "nothing is offered until the instance answers") |
| + await settle { !model.restoring } |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertTrue(model.canStart) |
| + XCTAssertFalse(model.unchecked) |
| + XCTAssertEqual(model.room, "") |
| + XCTAssertEqual(model.origin, kept.origin) |
| + XCTAssertEqual(server.requests, ["check \(kept.origin) \(kept.cookies)"]) |
| + } |
| + |
| + @MainActor func testAnEndedSessionIsForgottenAndTheInstanceLeftInTheField() async { |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.success("signed-out")]) |
| + let model = CallsModel(store: store, remote: server.remote) |
| + model.restore() |
| + await settle { !model.restoring && store.session == nil } |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertNil(store.session) |
| + XCTAssertEqual(model.address, kept.origin, "signing in again is one button") |
| + XCTAssertTrue(model.error.contains("calls.oficina.works")) |
| + } |
| + |
| + @MainActor func testABadConnectionKeepsTheSessionAndOffersToCheckAgain() async { |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.failure(Unreachable()), .success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote) |
| + model.restore() |
| + await settle { !model.restoring } |
| + XCTAssertEqual(store.session, kept, "no network says nothing about the session") |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertTrue(model.unchecked) |
| + XCTAssertTrue(model.error.contains("cannot reach Calls")) |
| + model.recheck() |
| + await settle { !model.restoring } |
| + XCTAssertFalse(model.unchecked) |
| + XCTAssertTrue(model.error.isEmpty) |
| + XCTAssertTrue(model.signedIn) |
| + } |
| + |
| + @MainActor func testASessionThatIsNoLongerAMembersIsNotKept() async { |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.success("participant")]) |
| + let model = CallsModel(store: store, remote: server.remote) |
| + model.restore() |
| + await settle { !model.restoring && store.session == nil } |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertNil(store.session) |
| + } |
| + |
| + @MainActor func testOnlyAFreshMembersSessionIsKept() async { |
| + for (answer, keptAfter, canStart) in [ |
| + (Result<String, Error>.success("member"), true, true), |
| + (.success("participant"), false, false), |
| + (.failure(Unreachable()), false, true), |
| + ] { |
| + let store = MemorySessionStore() |
| + let server = ScriptedRemote(checks: [answer]) |
| + let model = CallsModel(store: store, remote: server.remote) |
| + model.origin = kept.origin |
| + model.cookies = kept.cookies |
| + model.signedIn = true |
| + model.canStart = true |
| + model.keep(kept) |
| + await settle { !server.requests.isEmpty && (store.session != nil) == keptAfter } |
| + try? await Task.sleep(for: .milliseconds(50)) |
| + XCTAssertEqual(store.session != nil, keptAfter, "\(answer)") |
| + XCTAssertEqual(model.canStart, canStart, "\(answer)") |
| + } |
| + } |
| + |
| + @MainActor func testSigningOutForgetsAtOnceAndTellsTheInstance() async { |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote) |
| + model.restore() |
| + await settle { model.signedIn } |
| + model.room = "abc123" |
| + model.signOut() |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertFalse(model.canStart) |
| + XCTAssertEqual(model.room, "") |
| + XCTAssertEqual(model.origin, "") |
| + await settle { store.session == nil && server.requests.count == 2 } |
| + XCTAssertNil(store.session) |
| + XCTAssertEqual(server.requests.last, "signout \(kept.origin) \(kept.cookies)") |
| + } |
| + |
| + @MainActor func testSigningOutIsRefusedDuringACall() { |
| + let store = MemorySessionStore(kept) |
| + let model = CallsModel(store: store, remote: ScriptedRemote().remote) |
| + model.signedIn = true |
| + model.joining = true |
| + model.signOut() |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertEqual(store.session, kept) |
| + } |
| + |
| + @MainActor func testALinkOnTheSameInstanceOpensWithTheSessionNotTheBrowser() async { |
| + let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":""}"#]) |
| + let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) |
| + model.restore() |
| + await settle { model.signedIn } |
| + model.room = "old123" |
| + model.openMeetingURL( |
| + URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fj%2Finvitation")!) |
| + XCTAssertFalse(model.authenticating, "no browser sign-in for an instance already signed in to") |
| + XCTAssertTrue(model.signedIn) |
| + await settle { !model.opening } |
| + XCTAssertEqual(server.requests.last, "open \(kept.origin) https://calls.oficina.works/j/invitation") |
| + XCTAssertEqual(model.room, "", "the answer replaces the meeting in hand") |
| + XCTAssertEqual(model.address, "") |
| + } |
| + |
| + @MainActor func testALinkThatLaunchedTheAppWaitsForTheCheckAndThenOpens() async { |
| + let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":""}"#]) |
| + let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) |
| + model.restore() |
| + model.openMeetingURL( |
| + URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fm%2Fabc123")!) |
| + XCTAssertFalse(model.authenticating) |
| + await settle { server.requests.count == 2 && !model.opening } |
| + XCTAssertEqual( |
| + server.requests, |
| + [ |
| + "check \(kept.origin) \(kept.cookies)", |
| + "open \(kept.origin) https://calls.oficina.works/m/abc123", |
| + ]) |
| + } |
| + |
| + @MainActor func testTheInstanceIsComparedWithItsPortAndWithoutItsPath() { |
| + XCTAssertEqual( |
| + CallsModel.instance(of: "https://calls.oficina.works/j/abc"), "https://calls.oficina.works") |
| + XCTAssertEqual(CallsModel.instance(of: "http://127.0.0.1:8080/m/x"), "http://127.0.0.1:8080") |
| + XCTAssertNil(CallsModel.instance(of: "not a meeting")) |
| + } |
| + |
| + /// Not a unit test: the real Keychain, on the machine running it. Opt |
| + /// in with CALLS_KEYCHAIN_TEST=1, because on a Mac a development build |
| + /// falls back to the login keychain and a CI runner may not have one. |
| + func testTheKeychainKeepsAndForgets() throws { |
| + guard ProcessInfo.processInfo.environment["CALLS_KEYCHAIN_TEST"] == "1" else { |
| + throw XCTSkip("Set CALLS_KEYCHAIN_TEST=1 to use this machine's Keychain.") |
| + } |
| + let store = KeychainSessionStore(service: "net.amadan.oficina.calls.test-\(UUID().uuidString)") |
| + defer { store.clear() } |
| + XCTAssertNil(store.load()) |
| + store.save(kept) |
| + XCTAssertEqual(store.load(), kept) |
| + let replacement = StoredSession(origin: "https://other.test", cookies: "[]") |
| + store.save(replacement) |
| + XCTAssertEqual(store.load(), replacement, "one sign-in at a time") |
| + store.clear() |
| + XCTAssertNil(store.load()) |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift b/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift| index b4dc91b..e076f0b 100644 |
| --- a/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift |
| @@ -3,7 +3,7 @@ import XCTest |
| final class MeetingLinkTests: XCTestCase { |
| @MainActor func testInvitationOpensInLobbyWithoutJoiningOrStartingAuthentication() { |
| - let model = CallsModel() |
| + let model = CallsModel(store: MemorySessionStore()) |
| model.openMeetingURL(URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.example.com%2Fj%2Finvitation")!) |
| XCTAssertEqual(model.address, "https://calls.example.com/j/invitation") |
| XCTAssertFalse(model.joining) |
| @@ -11,7 +11,7 @@ final class MeetingLinkTests: XCTestCase { |
| } |
| @MainActor func testRejectsUntrustedURLShapesAndDoesNotInterruptCall() { |
| - let model = CallsModel() |
| + let model = CallsModel(store: MemorySessionStore()) |
| for target in ["file:///j/token", "https://user:secret@calls.example.com/j/token", "https://calls.example.com/settings"] { |
| var link = URLComponents(string: "oficina-calls://join")! |
| link.queryItems = [URLQueryItem(name: "url", value: target)] |
diff --git a/native/apple/Tests/CallsKitTests/StartMeetingTests.swift b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift| index f460fc9..78ed179 100644 |
| --- a/native/apple/Tests/CallsKitTests/StartMeetingTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift |
| @@ -5,12 +5,15 @@ import XCTest |
| /// The guard rails around starting a meeting. The request itself is Go's |
| /// (callscore.StartMeeting, gated server-side), so what is worth holding |
| /// here is the state the button is allowed to be in at all. |
| +/// |
| +/// Every model here has an in-memory store: none of these tests signs |
| +/// in, and none should be able to touch the Keychain of whoever runs it. |
| final class StartMeetingTests: XCTestCase { |
| - @MainActor func testStartingNeedsAnInstanceSignInAndSaysSoOtherwise() { |
| - let model = CallsModel() |
| - // Signed in by following a link: that session is for one meeting, and |
| - // the server would refuse the request. Refusing here means the app |
| - // does not spend a round trip learning that. |
| + @MainActor func testStartingIsRefusedOnlyOnceTheServerHasSaidSo() { |
| + let model = CallsModel(store: MemorySessionStore()) |
| + // canStart goes down only when the server answered that this session |
| + // is not a member's. Refusing here means the app does not spend a |
| + // round trip learning it again. |
| model.signedIn = true |
| model.room = "abc123" |
| model.canStart = false |
| @@ -21,7 +24,7 @@ final class StartMeetingTests: XCTestCase { |
| } |
| @MainActor func testStartingIsRefusedDuringACallRatherThanReplacingIt() { |
| - let model = CallsModel() |
| + let model = CallsModel(store: MemorySessionStore()) |
| model.signedIn = true |
| model.canStart = true |
| model.joining = true |
| @@ -36,7 +39,7 @@ final class StartMeetingTests: XCTestCase { |
| @MainActor func testLeavingAndCancellingBothPutTheStartingFlagDown() { |
| // `starting` gates the button. Left up by a leave or a cancel it would |
| // be a button nobody can press again for the rest of the session. |
| - let model = CallsModel() |
| + let model = CallsModel(store: MemorySessionStore()) |
| model.starting = true |
| model.cancelSignIn() |
| XCTAssertFalse(model.starting) |
| @@ -45,20 +48,22 @@ final class StartMeetingTests: XCTestCase { |
| XCTAssertFalse(model.starting) |
| } |
| - @MainActor func testAnInvitationClearsWhatTheLastSignInDecided() { |
| - let model = CallsModel() |
| + @MainActor func testALinkToAnotherInstanceClearsWhatTheLastSignInDecided() { |
| + let model = CallsModel(store: MemorySessionStore()) |
| + model.origin = "https://calls.oficina.works" |
| model.signedIn = true |
| model.canStart = true |
| model.room = "abc123" |
| model.openMeetingURL( |
| URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.example.com%2Fj%2Finvitation")!) |
| - XCTAssertFalse(model.canStart, "a link is a sign-in to one meeting, not to the instance") |
| + XCTAssertFalse(model.canStart, "another instance is another sign-in") |
| XCTAssertEqual(model.room, "") |
| XCTAssertFalse(model.signedIn) |
| + XCTAssertEqual(model.address, "https://calls.example.com/j/invitation") |
| } |
| @MainActor func testHostIsTheInstanceWithoutItsScheme() { |
| - let model = CallsModel() |
| + let model = CallsModel(store: MemorySessionStore()) |
| model.origin = "https://calls.oficina.works" |
| XCTAssertEqual(model.host, "calls.oficina.works") |
| model.origin = "http://127.0.0.1:8080" |
diff --git a/native/ios/README.md b/native/ios/README.md| index 2cedca4..ffd30ca 100644 |
| --- a/native/ios/README.md |
| +++ b/native/ios/README.md |
| @@ -36,7 +36,9 @@ available to Xcode, with Developer Mode enabled. |
| - Paste a meeting or invitation link; sign in in Safari/the default browser. |
| - Open the email link normally, compare the browser's code with Calls's code, |
| - approve, then return to Calls. Credentials are kept in memory. |
| + approve, then return to Calls. A member's session is kept in the Keychain |
| + (this device only) and checked with the server at the next launch; see |
| + [../README.md](../README.md). **Sign out** forgets it. |
| - Join using the front camera and microphone; the local video display is mirrored. |
| Sent video and remote video retain their original orientation. |
| - Native remote video, microphone/camera controls, guest admission, chat, and leave. |
| @@ -49,7 +51,7 @@ available to Xcode, with Developer Mode enabled. |
| Calls are foreground-only in this milestone. Switching away ends the call; |
| sign-in survives switching to the browser. Screen sharing, camera switching, |
| -CallKit/background calling, stored credentials, and distribution are follow-up |
| +CallKit/background calling, and distribution are follow-up |
| work. Physical camera, audible audio, Bluetooth routing, rotation while capturing, |
| and interruption recovery need a real-device acceptance pass. |
| @@ -64,7 +66,10 @@ CALLS_IOS_SIMULATOR=YOUR_SIMULATOR_UUID make iphone-browser |
| The Go harness creates isolated databases, meetings, and captured magic-link |
| emails. iPhone UI tests use the real Safari handoff for members and guests and |
| compare the authorization codes before returning to the app. No production |
| -credentials are used and no real email is sent. |
| +credentials are used and no real email is sent. The UI tests launch with |
| +`-CallsForgetSignIn YES`, which signs the app out before anything else: the |
| +simulator's Keychain outlives reinstalls, and a session kept by an earlier run |
| +against a harness server long gone would otherwise decide what the next run sees. |
| The native media test creates two iOS WebRTC peers with a synthetic camera, |
| checks decoded video in both directions, continuity through three-second stream |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 6bad8fd..cf9019c 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -30,7 +30,18 @@ final class CallsPhoneDelegate: NSObject, UIApplicationDelegate { |
| WindowGroup { |
| PhoneHome(model: model) |
| .onOpenURL { model.openMeetingURL($0) } |
| - .onAppear { CallsPhoneDelegate.model = model } |
| + .onAppear { |
| + CallsPhoneDelegate.model = model |
| + // UI tests launch with this set so a sign-in kept by an earlier |
| + // run, against a harness server long gone, cannot decide what |
| + // the next run sees first. The simulator's Keychain outlives the |
| + // app being reinstalled. |
| + if UserDefaults.standard.bool(forKey: "CallsForgetSignIn") { |
| + model.signOut() |
| + } else { |
| + model.restore() |
| + } |
| + } |
| .onChange(of: scenePhase) { _, phase in |
| // This milestone is foreground-only. Browser sign-in is kept alive; |
| // an active call leaves cleanly instead of silently sending audio. |
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index 70bcb31..ec56916 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -3,6 +3,7 @@ import XCTest |
| final class CallsPhoneUITests: XCTestCase { |
| @MainActor func testInvalidMeetingLink() { |
| let app = XCUIApplication() |
| + app.launchArguments += ["-CallsForgetSignIn", "YES"] |
| app.launch() |
| let input = app.textFields["meetingLink"] |
| XCTAssertTrue(input.waitForExistence(timeout: 15)) |
| @@ -24,6 +25,7 @@ final class CallsPhoneUITests: XCTestCase { |
| throw XCTSkip("Run the Go iPhone browser harness for a temporary server and captured email.") |
| } |
| let app = XCUIApplication() |
| + app.launchArguments += ["-CallsForgetSignIn", "YES"] |
| app.launch() |
| let input = app.textFields["meetingLink"] |
| XCTAssertTrue(input.waitForExistence(timeout: 15)) |