oficina / meet Public

Merge branch 'feat/native-remember-signin' into integrate/native-2026-09-23

Keeps the native sign-in in the Keychain, lands on home after it, and lets
callscore check, use and revoke the kept session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 00b96ab9f00d08f8d06e5a6571e2a63df4827ea5 parent 2180121parent 7b8ca77
18 files changed, +1374 −86
  • callscore/auth.go +35 −13
  • callscore/session.go +178 −0
  • callscore/session_test.go +179 −0
  • internal/calls/native_auth_test.go +47 −0
  • native/README.md +43 −14
  • native/apple/Sources/CallsInterop/LobbySnapshots.swift +8 −1
  • native/apple/Sources/CallsKit/CallsLobby.swift +55 −24
  • native/apple/Sources/CallsKit/CallsModel.swift +248 −12
  • native/apple/Sources/CallsKit/SessionStore.swift +146 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +4 −1
  • native/apple/Tests/CallsKitTests/KeptSignInTests.swift +238 −0
  • native/apple/Tests/CallsKitTests/MeetingLinkTests.swift +2 −2
  • native/apple/Tests/CallsKitTests/StartMeetingTests.swift +16 −11
  • native/bridge/bridge.go +72 −4
  • native/bridge/bridge_test.go +81 −0
  • native/ios/README.md +8 −3
  • native/ios/Sources/CallsPhone.swift +12 −1
  • native/ios/UITests/CallsPhoneUITests.swift +2 −0
diff --git a/callscore/auth.go b/callscore/auth.go
index ca3e589..550fd58 100644
--- a/callscore/auth.go
+++ b/callscore/auth.go
@@ -35,16 +35,21 @@ type SessionCookie struct {
Value string `json:"value"`
}
-func BeginAuthentication(ctx context.Context, address string) (*Authentication, error) {
+// meetingTarget splits an address a person pasted into the instance it
+// is on and the path the app may ask for there. It is shared by the
+// browser sign-in and by OpenLink, so an address means the same thing
+// whether or not the app already holds a session for its instance.
+//
+// An address with no path at all is the instance itself, and the
+// target is the meeting list: the person has no link because the
+// meeting they are about to be in does not exist yet. Everything
+// downstream of here treats an empty Room as that case.
+func meetingTarget(address string) (origin, target string, err error) {
u, err := url.Parse(address)
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
- return nil, errors.New("enter a full meeting or invitation link")
+ return "", "", errors.New("enter a full meeting or invitation link")
}
- // An address with no path at all is the instance itself, and the
- // target is the meeting list: the person signing in has no link
- // because the meeting they are about to be in does not exist yet.
- // Everything downstream of here treats an empty Room as that case.
- target := "/"
+ target = "/"
if trimmed := strings.Trim(u.Path, "/"); trimmed != "" {
parts := strings.Split(trimmed, "/")
if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" {
@@ -53,11 +58,30 @@ func BeginAuthentication(ctx context.Context, address string) (*Authentication,
if len(parts) != 2 || (parts[0] != "m" && parts[0] != "j") || len(parts[1]) == 0 || len(parts[1]) > 128 || strings.IndexFunc(parts[1], func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-')
}) >= 0 {
- return nil, errors.New("enter a full meeting or invitation link")
+ return "", "", errors.New("enter a full meeting or invitation link")
}
target = "/" + strings.Join(parts, "/")
}
- origin := u.Scheme + "://" + u.Host
+ return u.Scheme + "://" + u.Host, target, nil
+}
+
+// invitationRoom reads the answer to following an invitation with a
+// session: a 303 to the meeting's holding page, relative, on this
+// instance. Anything else — a sign-in page, the guest name form, a 404
+// for a replaced link — is not a meeting this session can enter.
+func invitationRoom(response *http.Response) (string, error) {
+ location, err := url.Parse(response.Header.Get("Location"))
+ if err != nil || response.StatusCode != 303 || location.IsAbs() || location.Host != "" || !strings.HasPrefix(location.Path, "/m/") || !strings.HasSuffix(location.Path, "/join") {
+ return "", errors.New("invitation is unavailable")
+ }
+ return strings.TrimSuffix(strings.TrimPrefix(location.Path, "/m/"), "/join"), nil
+}
+
+func BeginAuthentication(ctx context.Context, address string) (*Authentication, error) {
+ origin, target, err := meetingTarget(address)
+ if err != nil {
+ return nil, err
+ }
transport, err := newTransport(origin, "auth", &http.Client{})
if err != nil {
return nil, err
@@ -164,11 +188,9 @@ func (a *Authentication) Wait() (*AuthenticationResult, error) {
return nil, errors.New("cannot open invitation")
}
response.Body.Close()
- location, err := url.Parse(response.Header.Get("Location"))
- if err != nil || response.StatusCode != 303 || location.IsAbs() || location.Host != "" || !strings.HasPrefix(location.Path, "/m/") || !strings.HasSuffix(location.Path, "/join") {
- return nil, errors.New("invitation is unavailable")
+ if room, err = invitationRoom(response); err != nil {
+ return nil, err
}
- room = strings.TrimSuffix(strings.TrimPrefix(location.Path, "/m/"), "/join")
}
if _, e := newTransport(a.origin, room, a.client); e != nil {
return nil, e
diff --git a/callscore/session.go b/callscore/session.go
new file mode 100644
index 0000000..6f40203
--- /dev/null
+++ b/callscore/session.go
@@ -0,0 +1,178 @@
+package callscore
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// ErrSignedOut is the one refusal the app acts on rather than shows: the
+// session it is holding no longer signs anyone in, so the copy it kept
+// is forgotten and the person signs in again.
+var ErrSignedOut = errors.New("sign in to Calls again")
+
+// Standing is what a held session is on its instance, as far as the app
+// needs to know before it decides whether to keep it.
+type Standing string
+
+const (
+ // Member: a person on the team. This is the session worth keeping
+ // across launches — it may start meetings and follow any link.
+ Member Standing = "member"
+ // Participant: a live session that is not a member's — a guest who
+ // gave a name, or a verified address that was only invited. It is
+ // good for the meeting it came in through and nothing the app would
+ // want to wake up holding next week.
+ Participant Standing = "participant"
+)
+
+// CheckSession asks the instance what the held session is, with one GET
+// of a member-only page whose body is never read.
+//
+// There is no endpoint for the question, and none is added: the page
+// already answers it by status, on every server that has ever run the
+// native sign-in. /settings sits behind requireParticipant and then
+// requireMember, so a member's session gets the page, anyone else's
+// the 404 both refuse with, and a session that has gone the sign-in
+// redirect. Redirects are never followed (safeClient), so that 303
+// arrives as itself.
+//
+// Anything else is an answer this function cannot read — a network
+// failure, a 5xx, a proxy's page — and is reported as an error, never
+// as ErrSignedOut: the caller keeps its credential through a bad
+// connection and forgets it only when the instance says so.
+func CheckSession(ctx context.Context, origin string, client *http.Client) (Standing, error) {
+ trimmed, err := checkOrigin(origin)
+ if err != nil {
+ return "", err
+ }
+ safe, err := safeClient(client)
+ if err != nil {
+ return "", err
+ }
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+"/settings", nil)
+ if err != nil {
+ return "", errors.New("cannot build Calls request")
+ }
+ res, err := safe.Do(req)
+ if err != nil {
+ return "", errors.New("cannot reach Calls")
+ }
+ res.Body.Close()
+ switch {
+ case res.StatusCode == http.StatusOK:
+ return Member, nil
+ case res.StatusCode == http.StatusNotFound:
+ return Participant, nil
+ case res.StatusCode == http.StatusUnauthorized, res.StatusCode >= 300 && res.StatusCode < 400:
+ return "", ErrSignedOut
+ default:
+ return "", errors.New("cannot check your Calls sign-in just now")
+ }
+}
+
+// OpenLink turns a meeting or invitation address on the instance the
+// app is already signed in to into the meeting to join, without the
+// browser: the session in hand is the person's own, and a second
+// sign-in would only mint another one like it.
+//
+// An address with no path answers no meeting, which is the instance
+// itself. A /m/ address answers its identifier without asking: joining
+// is where access to it is decided. A /j/ address is followed exactly
+// as the browser would follow it with this session — which is what
+// consumes the invitation and, for someone who is not a member, grants
+// the meeting — and the room is read off the redirect.
+//
+// An address on another instance is refused. That instance is a
+// different sign-in, and the caller runs it.
+func OpenLink(ctx context.Context, origin, address string, client *http.Client) (string, error) {
+ trimmed, err := checkOrigin(origin)
+ if err != nil {
+ return "", err
+ }
+ linkOrigin, target, err := meetingTarget(address)
+ if err != nil {
+ return "", err
+ }
+ if linkOrigin != trimmed {
+ return "", errors.New("that link is for another Calls server")
+ }
+ if target == "/" {
+ return "", nil
+ }
+ if room, ok := strings.CutPrefix(target, "/m/"); ok {
+ if !validRoom(room) {
+ return "", errors.New("invalid meeting identifier")
+ }
+ return room, nil
+ }
+ safe, err := safeClient(client)
+ if err != nil {
+ return "", err
+ }
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+target, nil)
+ if err != nil {
+ return "", errors.New("cannot build Calls request")
+ }
+ res, err := safe.Do(req)
+ if err != nil {
+ return "", errors.New("cannot reach Calls")
+ }
+ res.Body.Close()
+ // followLink answers a gone session with the guest name form (200)
+ // or the sign-in redirect, never with an error: the link itself is
+ // fine, it is the person who has to say who they are.
+ if res.StatusCode == http.StatusOK || strings.HasPrefix(res.Header.Get("Location"), "/signin") {
+ return "", ErrSignedOut
+ }
+ room, err := invitationRoom(res)
+ if err != nil {
+ return "", err
+ }
+ if !validRoom(room) {
+ return "", errors.New("invitation is unavailable")
+ }
+ return room, nil
+}
+
+// SignOut revokes the held session on its instance: the same POST
+// /signout the web's button makes, carrying the Origin csrf.Protect
+// reads. Either sign-out route answers with a redirect once the row is
+// gone.
+//
+// Best effort, and the caller forgets its copy whatever this answers: a
+// person who pressed Sign out is signed out of this app, and a session
+// the server could not be told about still lapses at its thirty days.
+func SignOut(ctx context.Context, origin string, client *http.Client) error {
+ trimmed, err := checkOrigin(origin)
+ if err != nil {
+ return err
+ }
+ safe, err := safeClient(client)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/signout", strings.NewReader(""))
+ if err != nil {
+ return errors.New("cannot build Calls request")
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", trimmed)
+ res, err := safe.Do(req)
+ if err != nil {
+ return errors.New("cannot reach Calls")
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusSeeOther {
+ return errors.New("Calls did not confirm the sign-out")
+ }
+ return nil
+}
diff --git a/callscore/session_test.go b/callscore/session_test.go
new file mode 100644
index 0000000..fb2fe63
--- /dev/null
+++ b/callscore/session_test.go
@@ -0,0 +1,179 @@
+package callscore
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+func TestCheckSessionReadsTheSettingsPageByStatusAlone(t *testing.T) {
+ for _, c := range []struct {
+ status int
+ want Standing
+ }{
+ {http.StatusOK, Member},
+ // requireParticipant's refusal of a guest and requireMember's of
+ // an invited address are the same 404, and both mean the same
+ // thing here: live, and not worth keeping.
+ {http.StatusNotFound, Participant},
+ } {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" || r.URL.Path != "/settings" {
+ t.Errorf("asked %s %s", r.Method, r.URL.Path)
+ }
+ w.WriteHeader(c.status)
+ }))
+ got, err := CheckSession(t.Context(), server.URL, server.Client())
+ if err != nil || got != c.want {
+ t.Errorf("status %d gave %q, %v", c.status, got, err)
+ }
+ server.Close()
+ }
+}
+
+func TestCheckSessionOnlyCallsTheSignInRedirectSignedOut(t *testing.T) {
+ // The whole point of the distinction: a credential is forgotten when
+ // the instance says it is gone, and kept through anything else.
+ for _, c := range []struct {
+ handler http.HandlerFunc
+ signedOut bool
+ }{
+ {func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, true},
+ {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }, true},
+ {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, false},
+ {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadGateway) }, false},
+ } {
+ server := httptest.NewServer(c.handler)
+ _, err := CheckSession(t.Context(), server.URL, server.Client())
+ if err == nil {
+ t.Fatal("an unreadable answer was read")
+ }
+ if errors.Is(err, ErrSignedOut) != c.signedOut {
+ t.Errorf("signed out = %v, wanted %v (%v)", errors.Is(err, ErrSignedOut), c.signedOut, err)
+ }
+ server.Close()
+ }
+ // And an instance that cannot be reached at all says nothing about the
+ // session.
+ server := httptest.NewServer(http.NotFoundHandler())
+ origin := server.URL
+ server.Close()
+ if _, err := CheckSession(t.Context(), origin, &http.Client{}); err == nil || errors.Is(err, ErrSignedOut) {
+ t.Fatal("an unreachable instance signed the app out", err)
+ }
+}
+
+func TestCheckSessionNeverFollowsTheRedirect(t *testing.T) {
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ http.Redirect(w, r, "/signin", http.StatusSeeOther)
+ }))
+ defer server.Close()
+ CheckSession(t.Context(), server.URL, server.Client())
+ if calls.Load() != 1 {
+ t.Fatal("followed the redirect", calls.Load())
+ }
+}
+
+func TestOpenLinkAnswersAMeetingWithoutAskingAndTheInstanceAsNone(t *testing.T) {
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
+ defer server.Close()
+ for address, want := range map[string]string{
+ server.URL + "/m/abc123": "abc123",
+ server.URL + "/m/abc123/join": "abc123",
+ server.URL: "",
+ server.URL + "/": "",
+ } {
+ got, err := OpenLink(t.Context(), server.URL, address, server.Client())
+ if err != nil || got != want {
+ t.Errorf("%q gave %q, %v", address, got, err)
+ }
+ }
+ if calls.Load() != 0 {
+ t.Fatal("asked the server about something it did not need to", calls.Load())
+ }
+}
+
+func TestOpenLinkFollowsAnInvitationWithTheSessionAndReadsTheRoom(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/j/token_1" {
+ t.Errorf("followed %s", r.URL.Path)
+ }
+ http.Redirect(w, r, "/m/xyz789/join", http.StatusSeeOther)
+ }))
+ defer server.Close()
+ got, err := OpenLink(t.Context(), server.URL, server.URL+"/j/token_1", server.Client())
+ if err != nil || got != "xyz789" {
+ t.Fatal(got, err)
+ }
+}
+
+func TestOpenLinkTellsAGoneSessionFromAGoneLink(t *testing.T) {
+ for _, c := range []struct {
+ handler http.HandlerFunc
+ signedOut bool
+ }{
+ // followLink with no session: the guest name form, or the sign-in
+ // page for a meeting that requires one.
+ {func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("<form>")) }, true},
+ {func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, true},
+ // A replaced link is a 404 whoever follows it.
+ {func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }, false},
+ {func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "https://elsewhere.test/m/abc/join", http.StatusSeeOther)
+ }, false},
+ } {
+ server := httptest.NewServer(c.handler)
+ _, err := OpenLink(t.Context(), server.URL, server.URL+"/j/token", server.Client())
+ if err == nil {
+ t.Fatal("accepted a link that did not lead to a meeting")
+ }
+ if errors.Is(err, ErrSignedOut) != c.signedOut {
+ t.Errorf("signed out = %v, wanted %v (%v)", errors.Is(err, ErrSignedOut), c.signedOut, err)
+ }
+ server.Close()
+ }
+}
+
+func TestOpenLinkRefusesAnotherInstanceBeforeSendingItTheSession(t *testing.T) {
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
+ defer server.Close()
+ for _, address := range []string{"https://other.test/j/token", "https://other.test/m/abc", "not a link", server.URL + "/settings"} {
+ if _, err := OpenLink(t.Context(), server.URL, address, server.Client()); err == nil {
+ t.Errorf("accepted %q", address)
+ }
+ }
+ if calls.Load() != 0 {
+ t.Fatal("a refused address still reached a server", calls.Load())
+ }
+}
+
+func TestSignOutPostsWithTheOriginAndWantsTheRedirect(t *testing.T) {
+ var origin, method, path atomic.Value
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin.Store(r.Header.Get("Origin"))
+ method.Store(r.Method)
+ path.Store(r.URL.Path)
+ http.Redirect(w, r, "/signed-out", http.StatusSeeOther)
+ }))
+ defer server.Close()
+ if err := SignOut(t.Context(), server.URL, server.Client()); err != nil {
+ t.Fatal(err)
+ }
+ if method.Load() != "POST" || path.Load() != "/signout" || origin.Load() != server.URL {
+ t.Fatal("wrong request", method.Load(), path.Load(), origin.Load())
+ }
+ refused := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "cross-origin form submission refused", http.StatusForbidden)
+ }))
+ defer refused.Close()
+ if err := SignOut(t.Context(), refused.URL, refused.Client()); err == nil || !strings.Contains(err.Error(), "did not confirm") {
+ t.Fatal("a refused sign-out was reported as done", err)
+ }
+}
diff --git a/internal/calls/native_auth_test.go b/internal/calls/native_auth_test.go
index ebd70e5..211204c 100644
--- a/internal/calls/native_auth_test.go
+++ b/internal/calls/native_auth_test.go
@@ -329,3 +329,50 @@ func TestNativeHandoffWithoutAMeetingLandsOnTheList(t *testing.T) {
t.Fatal("target not carried back", ex.Body.String())
}
}
+
+// The app keeps a member's native session across launches and reads
+// three things off this server by status alone (callscore.CheckSession,
+// OpenLink and SignOut): /settings for whether the session is still a
+// member's, /j/{token} for the meeting an invitation opens, and
+// /signout for revoking it. None of them is a native endpoint, so this
+// is where a change to any of those answers is caught before it signs
+// every app out, or never does.
+func TestNativeSessionReadsWhatTheAppReads(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ m, token, err := f.app.store.createMeeting(t.Context(), "Kept session call", nil, "person:1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ browser := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
+ for _, c := range f.cookies {
+ browser.AddCookie(c)
+ }
+ session, ok := f.app.nativeAuth.sessions.From(browser)
+ if !ok {
+ t.Fatal("no browser session to hand over")
+ }
+ value, err := f.app.nativeAuth.sessions.Mint(session)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // From here on the fixture is the app: its own session and nothing
+ // the browser had.
+ name := f.app.nativeAuth.sessions.CookieName()
+ f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}}
+ if w := f.do("GET", "/settings", ""); w.Code != 200 {
+ t.Fatal("a member's native session is not read as one", w.Code)
+ }
+ if dest := homeRedirect(t, f.do("GET", "/j/"+token, "")); dest != "/m/"+m.Sqid+"/join" {
+ t.Fatal("invitation did not lead to its meeting", dest)
+ }
+ homeRedirect(t, f.do("POST", "/signout", ""))
+ // f.do dropped the cleared cookie; the app still holds its copy.
+ f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}}
+ if dest := homeRedirect(t, f.do("GET", "/settings", "")); !strings.HasPrefix(dest, signinPath) {
+ t.Fatal("a revoked session is not sent to sign in", dest)
+ }
+ if w := f.do("GET", "/j/"+token, ""); w.Code != 200 && !strings.HasPrefix(w.Header().Get("Location"), signinPath) {
+ t.Fatal("a revoked session followed an invitation", w.Code, w.Header().Get("Location"))
+ }
+}
diff --git a/native/README.md b/native/README.md
index 1a456ec..b034d82 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.
@@ -176,7 +204,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 f3434d6..253ca5d 100644
--- a/native/apple/Sources/CallsMac/CallsMac.swift
+++ b/native/apple/Sources/CallsMac/CallsMac.swift
@@ -17,7 +17,10 @@ import LiveKitWebRTC
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/bridge/bridge.go b/native/bridge/bridge.go
index 8784a66..cafacab 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -22,10 +22,12 @@ type Client struct{ room *callscore.Room }
// sessionJar turns the cookies the sign-in handoff returned into the jar
// every authenticated call in this package is made on. It is shared by
-// NewClient and StartMeeting so the rules about what counts as a session
-// — one cookie, the framework's name, thirty-two decoded bytes — are
-// written once. Cookies are scoped to the exact supplied origin and kept
-// in memory only.
+// every function here that takes cookies, so the rules about what counts
+// as a session — one cookie, the framework's name, thirty-two decoded
+// bytes — are written once. Cookies are scoped to the exact supplied
+// origin, and the jar lives only as long as the call that made it:
+// keeping the session between launches is the host's business, and
+// CallsKit keeps it in the Keychain.
func sessionJar(origin, cookiesJSON string) (*cookiejar.Jar, error) {
u, err := url.Parse(origin)
if err != nil {
@@ -115,3 +117,69 @@ func RegisterDevice(origin, cookiesJSON, token, platform string) error {
}
return callscore.RegisterDevice(context.Background(), origin, &http.Client{Jar: jar}, token, platform)
}
+
+// SignedOut is CheckSession's third answer, beside callscore's Member
+// and Participant: the instance says this session no longer signs
+// anyone in. It is a value rather than an error because the host acts
+// on it — forgets the copy it kept — and gomobile hands Swift every
+// error as the same NSError, told apart only by its words.
+const SignedOut = "signed-out"
+
+// CheckSession answers "member", "participant" or "signed-out" for a
+// session the host held on to, and an error only when the instance
+// could not be asked or its answer could not be read. The host keeps
+// its credential through an error and forgets it on "signed-out".
+//
+// Cookies this package would refuse to send are "signed-out" too: a
+// stored copy that no longer parses is no more use than an expired one.
+func CheckSession(origin, cookiesJSON string) (string, error) {
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err != nil {
+ return SignedOut, nil
+ }
+ standing, err := callscore.CheckSession(context.Background(), origin, &http.Client{Jar: jar})
+ if errors.Is(err, callscore.ErrSignedOut) {
+ return SignedOut, nil
+ }
+ if err != nil {
+ return "", err
+ }
+ return string(standing), nil
+}
+
+// OpenLink resolves a meeting or invitation address on the instance the
+// host is signed in to, without the browser, and answers JSON:
+// {"room": "..."} — empty for the instance's own address — or
+// {"signedOut": true} when the session has gone and the address needs a
+// fresh sign-in. JSON rather than a bare room so that "no meeting" and
+// "no session" cannot be mistaken for each other.
+func OpenLink(origin, cookiesJSON, address string) (string, error) {
+ type answer struct {
+ Room string `json:"room"`
+ SignedOut bool `json:"signedOut,omitempty"`
+ }
+ var out answer
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err == nil {
+ out.Room, err = callscore.OpenLink(context.Background(), origin, address, &http.Client{Jar: jar})
+ } else {
+ err = callscore.ErrSignedOut
+ }
+ if errors.Is(err, callscore.ErrSignedOut) {
+ out = answer{SignedOut: true}
+ } else if err != nil {
+ return "", err
+ }
+ b, err := json.Marshal(out)
+ return string(b), err
+}
+
+// SignOut asks the instance to revoke the session. The host forgets its
+// copy whatever this answers; see callscore.SignOut.
+func SignOut(origin, cookiesJSON string) error {
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err != nil {
+ return err
+ }
+ return callscore.SignOut(context.Background(), origin, &http.Client{Jar: jar})
+}
diff --git a/native/bridge/bridge_test.go b/native/bridge/bridge_test.go
index d0789c9..8b9ec9d 100644
--- a/native/bridge/bridge_test.go
+++ b/native/bridge/bridge_test.go
@@ -2,6 +2,9 @@ package bridge
import (
"encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
"testing"
)
@@ -29,3 +32,81 @@ func TestBridgeRequiresHumanSessionAndSafeOrigin(t *testing.T) {
}
c.Close()
}
+
+// loopbackSession is a session cookie as the handoff returns it for an
+// http://127.0.0.1 instance, which is the one place a test server can
+// be and the bridge will still send it.
+func loopbackSession() (string, string) {
+ token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
+ return token, `[{"name":"rastrillo_session","value":"` + token + `"}]`
+}
+
+func TestCheckSessionCarriesTheCookieAndAnswersAValue(t *testing.T) {
+ token, cookies := loopbackSession()
+ status := http.StatusOK
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie("rastrillo_session"); err != nil || c.Value != token {
+ t.Error("session not sent")
+ }
+ if status == http.StatusSeeOther {
+ http.Redirect(w, r, "/signin", status)
+ return
+ }
+ w.WriteHeader(status)
+ }))
+ defer server.Close()
+ for s, want := range map[int]string{http.StatusOK: "member", http.StatusNotFound: "participant", http.StatusSeeOther: SignedOut} {
+ status = s
+ got, err := CheckSession(server.URL, cookies)
+ if err != nil || got != want {
+ t.Errorf("status %d gave %q, %v", s, got, err)
+ }
+ }
+ status = http.StatusBadGateway
+ if got, err := CheckSession(server.URL, cookies); err == nil || got == SignedOut {
+ t.Fatal("a bad gateway signed the app out", got, err)
+ }
+}
+
+func TestCheckSessionCallsAStoredCopyItCannotSendSignedOut(t *testing.T) {
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
+ defer server.Close()
+ for _, cookies := range []string{``, `[]`, `not json`, `[{"name":"rastrillo_session","value":"short"}]`} {
+ if got, err := CheckSession(server.URL, cookies); err != nil || got != SignedOut {
+ t.Errorf("%q gave %q, %v", cookies, got, err)
+ }
+ }
+ if calls.Load() != 0 {
+ t.Fatal("sent a session it should have refused", calls.Load())
+ }
+}
+
+func TestOpenLinkKeepsNoMeetingAndNoSessionApart(t *testing.T) {
+ _, cookies := loopbackSession()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/j/live":
+ http.Redirect(w, r, "/m/abc123/join", http.StatusSeeOther)
+ case "/j/gone":
+ http.Redirect(w, r, "/signin", http.StatusSeeOther)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+ for address, want := range map[string]string{
+ server.URL: `{"room":""}`,
+ server.URL + "/j/live": `{"room":"abc123"}`,
+ server.URL + "/j/gone": `{"room":"","signedOut":true}`,
+ server.URL + "/m/xyz99": `{"room":"xyz99"}`,
+ } {
+ got, err := OpenLink(server.URL, cookies, address)
+ if err != nil || got != want {
+ t.Errorf("%q gave %s, %v", address, got, err)
+ }
+ }
+ if _, err := OpenLink(server.URL, cookies, server.URL+"/j/replaced"); err == nil {
+ t.Fatal("a replaced link opened")
+ }
+}
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 6613f09..ab160d9 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))