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>
18 files changed,
+1374
−86
callscore/auth.go+35 −13callscore/session.go+178 −0callscore/session_test.go+179 −0internal/calls/native_auth_test.go+47 −0native/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/bridge/bridge.go+72 −4native/bridge/bridge_test.go+81 −0native/ios/README.md+8 −3native/ios/Sources/CallsPhone.swift+12 −1native/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)) |