oficina / meet Public

Merge branch 'feat/native-start-meeting' into main

claude agent, operated by paul@keymail.dev e4e31901b625c63ade9155555cd69592675b6306 parent 6440c84parent a262bba
39 files changed, +1961 −62
  • callscore/auth.go +23 −9
  • callscore/auth_test.go +54 −0
  • callscore/meetings.go +75 −0
  • callscore/meetings_test.go +113 −0
  • callscore/transport.go +41 −9
  • internal/calls/app.go +1 −0
  • internal/calls/native_auth.go +53 −0
  • internal/calls/native_auth_test.go +131 −0
  • internal/calls/static/devices.js +13 −3
  • internal/calls/static/room.js +13 −3
  • internal/callstest/browser_test.go +37 −0
  • internal/callstest/native_browser_test.go +4 −0
  • native/README.md +72 −5
  • native/apple/Sources/CallsInterop/Interop.swift +8 −0
  • native/apple/Sources/CallsInterop/SyntheticVideo.swift +27 −1
  • native/apple/Sources/CallsKit/Audio.swift +168 −0
  • native/apple/Sources/CallsKit/AudioHealth.swift +79 −0
  • native/apple/Sources/CallsKit/Call.swift +91 −0
  • native/apple/Sources/CallsKit/CallsLobby.swift +47 −9
  • native/apple/Sources/CallsKit/CallsModel.swift +64 −0
  • native/apple/Sources/CallsKit/Capture.swift +88 −3
  • native/apple/Sources/CallsKit/DevicePanel.swift +154 −0
  • native/apple/Sources/CallsKit/Knocks.swift +101 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +19 −0
  • native/apple/Sources/CallsKit/Media.swift +49 −1
  • native/apple/Tests/CallsKitTests/AudioHealthTests.swift +123 −0
  • native/apple/Tests/CallsKitTests/KnockWatchTests.swift +84 −0
  • native/apple/Tests/CallsKitTests/StartMeetingTests.swift +67 −0
  • native/bridge/bridge.go +33 −7
  • native/ios/Sources/CallsPhone.swift +16 −0
  • native/ios/Tests/AudioRouteTests.swift +39 −0
  • test/browser/audio.test.mjs +20 −5
  • test/browser/chat.test.mjs +1 −1
  • test/browser/devices.test.mjs +2 −2
  • test/browser/native-auth.mjs +13 −1
  • test/browser/native.mjs +35 −0
  • test/browser/negotiation.test.mjs +1 −1
  • test/browser/room-lane.test.mjs +1 −1
  • test/browser/room-share.test.mjs +1 −1

CI failed — run details

diff --git a/callscore/auth.go b/callscore/auth.go
index 15e4847..ca3e589 100644
--- a/callscore/auth.go
+++ b/callscore/auth.go
@@ -40,14 +40,22 @@ func BeginAuthentication(ctx context.Context, address string) (*Authentication,
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
return nil, errors.New("enter a full meeting or invitation link")
}
- parts := strings.Split(strings.Trim(u.Path, "/"), "/")
- if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" {
- parts = parts[:2]
- }
- 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")
+ // 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 := "/"
+ if trimmed := strings.Trim(u.Path, "/"); trimmed != "" {
+ parts := strings.Split(trimmed, "/")
+ if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" {
+ parts = parts[:2]
+ }
+ 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")
+ }
+ target = "/" + strings.Join(parts, "/")
}
origin := u.Scheme + "://" + u.Host
transport, err := newTransport(origin, "auth", &http.Client{})
@@ -60,7 +68,7 @@ func BeginAuthentication(ctx context.Context, address string) (*Authentication,
cancel()
return nil, errors.New("cannot start sign-in")
}
- a := &Authentication{origin: origin, verifier: base64.RawURLEncoding.EncodeToString(raw), target: "/" + strings.Join(parts, "/"), client: transport.http, ctx: ctx, cancel: cancel}
+ a := &Authentication{origin: origin, verifier: base64.RawURLEncoding.EncodeToString(raw), target: target, client: transport.http, ctx: ctx, cancel: cancel}
hash := sha256.Sum256([]byte(a.verifier))
res, err := a.post("request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(hash[:]), "target": a.target})
if err != nil {
@@ -141,6 +149,12 @@ func (a *Authentication) Wait() (*AuthenticationResult, error) {
if res.StatusCode != 200 || err != nil || out.Name != expected || !authToken(out.Value) || out.Target != a.target {
return nil, errors.New("sign-in expired or was refused; try again")
}
+ // The meeting list signs in without a meeting: there is no room
+ // to name, no invitation to follow and nothing to validate. The
+ // host reads the empty Room as "signed in, nowhere yet".
+ if a.target == "/" {
+ return &AuthenticationResult{Origin: a.origin, Cookies: []SessionCookie{{Name: out.Name, Value: out.Value}}}, nil
+ }
room := strings.TrimPrefix(a.target, "/m/")
if strings.HasPrefix(a.target, "/j/") {
req, _ := http.NewRequestWithContext(a.ctx, "GET", a.origin+a.target, nil)
diff --git a/callscore/auth_test.go b/callscore/auth_test.go
index d53b018..634e33e 100644
--- a/callscore/auth_test.go
+++ b/callscore/auth_test.go
@@ -75,3 +75,57 @@ func TestAuthenticationRejectsUnsafeAddress(t *testing.T) {
}
}
}
+
+func TestAuthenticationWithoutAPathTargetsTheMeetingList(t *testing.T) {
+ // The person who has no link, because the meeting they are about to be
+ // in does not exist yet. The target is the list, and the result carries
+ // no room: the host reads that as "signed in, nowhere yet".
+ var target atomic.Value
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var in map[string]string
+ json.NewDecoder(r.Body).Decode(&in)
+ switch r.URL.Path {
+ case "/native/request":
+ target.Store(in["target"])
+ json.NewEncoder(w).Encode(map[string]string{"id": strings.Repeat("A", 43)})
+ case "/native/exchange":
+ json.NewEncoder(w).Encode(map[string]string{
+ // 43 base64url zeros: the only 43-character run that both
+ // decodes to thirty-two bytes and re-encodes to itself,
+ // which is what authToken insists on.
+ "name": "rastrillo_session", "value": strings.Repeat("A", 43), "target": "/",
+ })
+ }
+ }))
+ defer server.Close()
+ for _, address := range []string{server.URL, server.URL + "/"} {
+ a, err := BeginAuthentication(t.Context(), address)
+ if err != nil {
+ t.Fatal(address, err)
+ }
+ if target.Load() != "/" {
+ t.Fatal("wrong target", target.Load())
+ }
+ result, err := a.Wait()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Room != "" {
+ t.Fatal("a list sign-in named a meeting", result.Room)
+ }
+ if len(result.Cookies) != 1 {
+ t.Fatal("no session")
+ }
+ }
+}
+
+func TestAuthenticationStillRejectsAPathThatIsNotAMeeting(t *testing.T) {
+ // Widening the empty path to mean "the instance" must not widen
+ // anything else: a path that is present and wrong is still an error,
+ // not a quiet fallback to the list.
+ for _, address := range []string{"https://example.test/settings", "https://example.test/m", "https://example.test/m/a/b", "https://example.test/x/abc"} {
+ if _, err := BeginAuthentication(t.Context(), address); err == nil {
+ t.Errorf("accepted %q", address)
+ }
+ }
+}
diff --git a/callscore/meetings.go b/callscore/meetings.go
new file mode 100644
index 0000000..bac90e6
--- /dev/null
+++ b/callscore/meetings.go
@@ -0,0 +1,75 @@
+package callscore
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "time"
+)
+
+// StartMeeting creates a meeting on the instance this client is signed
+// in to and answers its identifier. It is the app's half of the meeting
+// list's one-click button, and it exists because that button's web
+// answer is the link-once *page* — a screen, and not something a Swift
+// app can read without pretending to be a browser.
+//
+// The identifier is all it answers. A link is a credential, and the
+// room already has a share flow that mints one when a member asks for
+// it (Client.ShareLink), so nothing that merely starts a meeting has to
+// hold one, log one, or decide when to forget it.
+//
+// The session is the caller's: an ordinary browser session carried in
+// the client's cookie jar, exactly as Room uses. Nothing here mints or
+// accepts an agent bearer token.
+func StartMeeting(ctx context.Context, origin string, client *http.Client) (string, 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+"/native/meetings/start", bytes.NewReader([]byte("{}")))
+ if err != nil {
+ return "", errors.New("cannot build Calls request")
+ }
+ req.Header.Set("Accept", "application/json")
+ // Both locks the server asks for: the content type an HTML form
+ // cannot send, and the origin csrf.Protect reads. A POST carrying
+ // neither is refused before the handler sees it.
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", trimmed)
+ res, err := safe.Do(req)
+ if err != nil {
+ return "", errors.New("cannot reach Calls")
+ }
+ defer res.Body.Close()
+ switch {
+ case res.StatusCode == http.StatusOK:
+ case res.StatusCode == http.StatusUnauthorized, res.StatusCode == http.StatusSeeOther:
+ // The session went while the app was holding it. Redirects are
+ // never followed here, so a sign-in page arrives as its 303 and
+ // is read as one rather than decoded as JSON.
+ return "", errors.New("sign in to Calls again")
+ case res.StatusCode == http.StatusForbidden, res.StatusCode == http.StatusNotFound:
+ // requireMember's refusal. A guest may join a meeting and may
+ // not start one, and saying which would tell an outsider
+ // something about the team.
+ return "", errors.New("this account cannot start meetings on that Calls server")
+ default:
+ return "", errors.New("cannot start a meeting just now")
+ }
+ var out struct {
+ Sqid string `json:"sqid"`
+ }
+ if json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&out) != nil || !validRoom(out.Sqid) {
+ return "", errors.New("Calls answered with a meeting this app cannot open")
+ }
+ return out.Sqid, nil
+}
diff --git a/callscore/meetings_test.go b/callscore/meetings_test.go
new file mode 100644
index 0000000..d96b42c
--- /dev/null
+++ b/callscore/meetings_test.go
@@ -0,0 +1,113 @@
+package callscore
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+func TestStartMeetingSendsBothLocksAndAnswersTheSqid(t *testing.T) {
+ var origin, contentType, method, path atomic.Value
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin.Store(r.Header.Get("Origin"))
+ contentType.Store(r.Header.Get("Content-Type"))
+ method.Store(r.Method)
+ path.Store(r.URL.Path)
+ json.NewEncoder(w).Encode(map[string]string{"sqid": "abc123", "title": "Call, 17 Sep 11:23"})
+ }))
+ defer server.Close()
+ sqid, err := StartMeeting(t.Context(), server.URL, server.Client())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sqid != "abc123" {
+ t.Fatal("wrong meeting", sqid)
+ }
+ if method.Load() != "POST" || path.Load() != "/native/meetings/start" {
+ t.Fatal("wrong request", method.Load(), path.Load())
+ }
+ // csrf.Protect refuses a POST that carries neither, and it is worth a
+ // test because the failure is a 403 on a live server and nothing a
+ // unit test of the handler would ever see.
+ if origin.Load() != server.URL {
+ t.Fatal("origin not sent", origin.Load())
+ }
+ if contentType.Load() != "application/json" {
+ t.Fatal("content type not sent", contentType.Load())
+ }
+}
+
+func TestStartMeetingRefusesAnAnswerItCannotOpen(t *testing.T) {
+ for _, body := range []string{`{"sqid":""}`, `{"sqid":"../admin"}`, `{"sqid":"has space"}`, `{"sqid":"` + strings.Repeat("a", 33) + `"}`, `not json`, `{}`} {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(body))
+ }))
+ if _, err := StartMeeting(t.Context(), server.URL, server.Client()); err == nil {
+ t.Errorf("accepted %q", body)
+ }
+ server.Close()
+ }
+}
+
+func TestStartMeetingNeverFollowsARedirectWithTheSession(t *testing.T) {
+ // A session that went comes back as the sign-in page's redirect. Follow
+ // it and the app carries a credential to wherever it points, then
+ // decodes a page as JSON.
+ 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()
+ _, err := StartMeeting(t.Context(), server.URL, server.Client())
+ if err == nil {
+ t.Fatal("redirect accepted")
+ }
+ if !strings.Contains(err.Error(), "sign in") {
+ t.Fatal("unhelpful error", err)
+ }
+ if calls.Load() != 1 {
+ t.Fatal("followed the redirect", calls.Load())
+ }
+}
+
+func TestStartMeetingRefusesAnUnsafeOriginBeforeSendingAnything(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 _, origin := range []string{"http://example.test", "https://alice:secret@example.test", "https://example.test?x=1", "https://example.test/m/abc", "", "not a url"} {
+ if _, err := StartMeeting(t.Context(), origin, server.Client()); err == nil {
+ t.Errorf("accepted %q", origin)
+ }
+ }
+ if calls.Load() != 0 {
+ t.Fatal("a refused origin still reached a server", calls.Load())
+ }
+ if _, err := StartMeeting(t.Context(), server.URL, nil); err == nil {
+ t.Fatal("accepted a client with no session")
+ }
+}
+
+func TestStartMeetingNamesTheRefusalItCanDoSomethingAbout(t *testing.T) {
+ for _, c := range []struct {
+ status int
+ want string
+ }{
+ {http.StatusUnauthorized, "sign in"},
+ {http.StatusForbidden, "cannot start meetings"},
+ {http.StatusNotFound, "cannot start meetings"},
+ {http.StatusInternalServerError, "just now"},
+ } {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "no", c.status)
+ }))
+ _, err := StartMeeting(t.Context(), server.URL, server.Client())
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("status %d gave %v, wanted something about %q", c.status, err, c.want)
+ }
+ server.Close()
+ }
+}
diff --git a/callscore/transport.go b/callscore/transport.go
index f7f830a..2c4ebd2 100644
--- a/callscore/transport.go
+++ b/callscore/transport.go
@@ -39,22 +39,39 @@ type transport struct {
http *http.Client
}
-func newTransport(origin, room string, client *http.Client) (*transport, error) {
+// checkOrigin is the one place an origin is judged, so a request made
+// outside a transport — StartMeeting is the only one — cannot reach a
+// server this package would otherwise refuse. It answers the origin
+// without its trailing slash, which is also the value the Origin header
+// has to carry for csrf.Protect to recognise it.
+func checkOrigin(origin string) (string, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
- return nil, errors.New("enter a Calls server origin")
+ return "", errors.New("enter a Calls server origin")
}
if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1")) {
- return nil, errors.New("Calls requires HTTPS (HTTP is allowed only on loopback)")
- }
- if room == "" || len(room) > 32 || strings.IndexFunc(room, func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') }) >= 0 {
- return nil, errors.New("invalid meeting identifier")
+ return "", errors.New("Calls requires HTTPS (HTTP is allowed only on loopback)")
}
+ return strings.TrimSuffix(origin, "/"), nil
+}
+
+// validRoom is the meeting identifier's shape, checked both on the way
+// in from a person and on the way back from the server: a sqid this app
+// cannot open is a bad answer, not a meeting.
+func validRoom(room string) bool {
+ return room != "" && len(room) <= 32 && strings.IndexFunc(room, func(r rune) bool {
+ return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9')
+ }) < 0
+}
+
+// safeClient wraps the caller's client for every request this package
+// makes. Never follow a redirect with a credential or accidentally decode a
+// sign-in page as protocol JSON. The caller's transport and cookie jar are
+// retained.
+func safeClient(client *http.Client) (*http.Client, error) {
if client == nil {
return nil, errors.New("an authenticated HTTP client is required")
}
- // Never follow a redirect with a credential or accidentally decode a sign-in
- // page as protocol JSON. The caller's transport and cookie jar are retained.
safe := *client
if safe.Transport == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
@@ -63,7 +80,22 @@ func newTransport(origin, room string, client *http.Client) (*transport, error)
}
safe.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
safe.Timeout = 0 // streams live for five minutes; individual calls have deadlines
- return &transport{strings.TrimSuffix(origin, "/"), room, &safe}, nil
+ return &safe, nil
+}
+
+func newTransport(origin, room string, client *http.Client) (*transport, error) {
+ trimmed, err := checkOrigin(origin)
+ if err != nil {
+ return nil, err
+ }
+ if !validRoom(room) {
+ return nil, errors.New("invalid meeting identifier")
+ }
+ safe, err := safeClient(client)
+ if err != nil {
+ return nil, err
+ }
+ return &transport{trimmed, room, safe}, nil
}
func (t *transport) request(ctx context.Context, method, endpoint string, q url.Values, body []byte) (*http.Response, error) {
diff --git a/internal/calls/app.go b/internal/calls/app.go
index c0fe7f2..cf30892 100644
--- a/internal/calls/app.go
+++ b/internal/calls/app.go
@@ -472,6 +472,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Post("/account/home/confirm", a.home.routes.ConfirmPost)
}
r.Post("/meetings/start", a.startCall)
+ r.Post("/native/meetings/start", a.nativeStartMeeting)
r.Post("/meetings", a.createMeeting)
r.Post("/m/{sqid}/share", a.shareMeeting)
r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink)
diff --git a/internal/calls/native_auth.go b/internal/calls/native_auth.go
index 03b76c7..12c11c6 100644
--- a/internal/calls/native_auth.go
+++ b/internal/calls/native_auth.go
@@ -50,7 +50,24 @@ func nativeToken(s string) bool {
b, e := base64.RawURLEncoding.DecodeString(s)
return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
}
+
+// nativeTarget is where the app may ask the browser to land, and the
+// set is closed on purpose: a target is written into a pending request
+// by an unauthenticated caller and later redirected to with the
+// person's own session, so anything open here is an open redirect with
+// an ambient credential behind it.
+//
+// Two shapes are one meeting — /m/{sqid} and /j/{token} — and the third
+// is "/", the meeting list. "/" is how the app signs in to an instance
+// rather than to a meeting: the person who has no link yet, because the
+// meeting they are about to be in does not exist until they start it.
+// An app that does hold a link still names it, because landing on the
+// link is what consumes an invitation (spec §3.2) and "/" consumes
+// nothing.
func nativeTarget(s string) bool {
+ if s == "/" {
+ return true
+ }
if !strings.HasPrefix(s, "/") {
return false
}
@@ -256,3 +273,39 @@ func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) {
}
render(w, "native", a.page(r, map[string]any{"Title": title, "Done": true}))
}
+
+// nativeStartMeeting is POST /native/meetings/start: the app's half of
+// the list's one-click button. The web answers that button with the
+// link-once page, which is a screen and not something a Swift app can
+// read; this answers the one fact the app needs — the meeting's sqid —
+// and leaves the link to the share flow the room already has.
+//
+// It is the same store.createMeeting every other meeting goes through,
+// under the same requireMember as POST /meetings/start, so nothing
+// about who may start a meeting is decided twice.
+//
+// Ambient authority, unlike /native/request and /native/exchange: this
+// runs on the session the app is holding. That is what csrf.Protect
+// above is for — callscore sends Origin on every POST — and the JSON
+// content type is the second lock, because an HTML form cannot send
+// one and a cross-origin fetch that tries is stopped at the preflight.
+func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) {
+ nativeHeaders(w)
+ if r.Header.Get("Content-Type") != "application/json" {
+ http.Error(w, "expected JSON", http.StatusUnsupportedMediaType)
+ return
+ }
+ p, _ := currentPerson(r)
+ title := callTitle(a.store.now(), a.cfg.zone)
+ m, _, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p))
+ if err != nil {
+ a.logger.Error("calls: native start meeting", "err", err)
+ http.Error(w, "cannot start a meeting", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ // The title is the server's, not the app's: callTitle writes it in
+ // the instance zone, and an app in another one would otherwise name
+ // the meeting an hour it is not.
+ json.NewEncoder(w).Encode(map[string]string{"sqid": m.Sqid, "title": title})
+}
diff --git a/internal/calls/native_auth_test.go b/internal/calls/native_auth_test.go
index e5bdef1..ebd70e5 100644
--- a/internal/calls/native_auth_test.go
+++ b/internal/calls/native_auth_test.go
@@ -198,3 +198,134 @@ func TestNativeGuestInvitationResumesAuthorizationWithoutSignin(t *testing.T) {
t.Fatal("guest lost native handoff", dest)
}
}
+
+// nativeMemberJSON is nativeJSON with the session and the origin: the
+// start endpoint runs on ambient authority, unlike /native/request and
+// /native/exchange, so both of the locks it sits behind have to be in
+// the request for the handler to be reached at all.
+func nativeMemberJSON(f *homeRouterFixture, path, body string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("Origin", homeTestOrigin)
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ return w
+}
+
+func TestNativeStartMeetingAnswersASqidAMemberCanOpen(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ w := nativeMemberJSON(f, "/native/meetings/start", "{}")
+ if w.Code != 200 {
+ t.Fatal("start refused", w.Code, w.Body.String())
+ }
+ if got := w.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatal("meeting identifier is cacheable", got)
+ }
+ var out map[string]string
+ if json.Unmarshal(w.Body.Bytes(), &out) != nil || !validSqid(out["sqid"]) {
+ t.Fatal("no usable sqid", w.Body.String())
+ }
+ if out["title"] == "" {
+ t.Fatal("no title")
+ }
+ // The sqid is only worth answering if it opens: the app's next act
+ // is to join it, on the same session.
+ if got := f.do("GET", "/m/"+out["sqid"]+"/join", ""); got.Code != 200 {
+ t.Fatal("started meeting will not open", got.Code)
+ }
+}
+
+func TestNativeStartMeetingRefusesWithoutBothLocks(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ // A form content type is the cross-site shape: an HTML form can send
+ // it, and nothing else about the request would stop it.
+ form := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader(""))
+ form.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ form.Header.Set("Origin", homeTestOrigin)
+ for _, c := range f.cookies {
+ form.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, form)
+ if w.Code != http.StatusUnsupportedMediaType {
+ t.Fatal("form post accepted", w.Code)
+ }
+ // And no origin at all is csrf.Protect's refusal, before the handler.
+ bare := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader("{}"))
+ bare.Header.Set("Content-Type", "application/json")
+ for _, c := range f.cookies {
+ bare.AddCookie(c)
+ }
+ w = httptest.NewRecorder()
+ f.handler.ServeHTTP(w, bare)
+ if w.Code == 200 {
+ t.Fatal("origin-less post accepted")
+ }
+ var count int
+ if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM meetings").Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Fatal("a refused request still created a meeting", count)
+ }
+}
+
+func TestNativeStartMeetingRefusesASignedOutApp(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ if w := nativeMemberJSON(f, "/native/meetings/start", "{}"); w.Code == 200 {
+ t.Fatal("signed-out start accepted")
+ }
+}
+
+func TestNativeTargetAcceptsTheMeetingListAndNothingElseUnpathed(t *testing.T) {
+ // "/" is the app signing in to an instance rather than to a meeting:
+ // the person who has no link because the meeting does not exist yet.
+ if !nativeTarget("/") {
+ t.Fatal("the meeting list is not a target")
+ }
+ for _, target := range []string{"", "//", "/?next=evil", "/#evil", "/welcome", "/settings", "/signin"} {
+ if nativeTarget(target) {
+ t.Errorf("unsafe or unintended target %q", target)
+ }
+ }
+}
+
+func TestNativeHandoffWithoutAMeetingLandsOnTheList(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ v := nativeRandom()
+ h := sha256.Sum256([]byte(v))
+ w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/"})
+ var out map[string]string
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil {
+ t.Fatal("request refused", w.Code)
+ }
+ id := out["id"]
+ // No /join is appended to the list the way it is to a meeting.
+ if got := homeRedirect(t, f.do("GET", "/native/start?id="+id, "")); got != "/" {
+ t.Fatal("did not land on the list", got)
+ }
+ // And the resume middleware carries an already-signed-in person
+ // straight to the consent screen from "/", which is the whole point
+ // of the list being a target at all.
+ if got := homeRedirect(t, f.do("GET", "/", "")); got != "/native/authorize?id="+id {
+ t.Fatal("no continuation from the list", got)
+ }
+ if page := f.do("GET", "/native/authorize?id="+id, ""); page.Code != 200 {
+ t.Fatal("no consent screen", page.Code)
+ }
+ homeRedirect(t, f.do("POST", "/native/authorize?id="+id, ""))
+ ex := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v})
+ if ex.Code != 200 {
+ t.Fatal("exchange refused", ex.Code, ex.Body.String())
+ }
+ var session map[string]string
+ if json.Unmarshal(ex.Body.Bytes(), &session) != nil || session["target"] != "/" {
+ t.Fatal("target not carried back", ex.Body.String())
+ }
+}
diff --git a/internal/calls/static/devices.js b/internal/calls/static/devices.js
index 79fc997..73ca423 100644
--- a/internal/calls/static/devices.js
+++ b/internal/calls/static/devices.js
@@ -90,8 +90,18 @@
// the holding screen checks afterwards what it was actually given
// (rawHonoured) instead of trusting that it got what it asked for.
//
- // voiceIsolation is the newer, heavier filter in Safari and Chrome,
- // and it is not everywhere. An unknown constraint name in this basic,
+ // voiceIsolation is the newer, heavier filter in Safari and Chrome —
+ // on a Mac it is Apple's own system Voice Isolation, reached through
+ // the browser. It is only ever asked for *off*, and never on. Asked
+ // on, it runs on top of the noise suppression above rather than
+ // instead of it, and two suppressors chewing one voice is not twice
+ // as clean: it is a growl with the keystrokes still coming through,
+ // which is what a call on 2026-09-17 came back as. The pipeline above
+ // is every browser's own default and is the one this app asks for.
+ //
+ // Off it still belongs here, because someone may have turned Voice
+ // Isolation on for the whole machine and studio audio has to be able
+ // to say no to it. An unknown constraint name in this basic,
// non-exact form is dropped rather than refused, so naming it costs
// nothing in a browser that has never heard of it.
function audioConstraints(prefs) {
@@ -100,8 +110,8 @@
echoCancellation: processed,
noiseSuppression: processed,
autoGainControl: processed,
- voiceIsolation: processed,
};
+ if (!processed) constraints.voiceIsolation = false;
if (prefs.mic) constraints.deviceId = { exact: prefs.mic };
return constraints;
}
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index ba339f1..8b3b909 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -359,8 +359,18 @@
// the holding screen checks afterwards what it was actually given
// (rawHonoured) instead of trusting that it got what it asked for.
//
- // voiceIsolation is the newer, heavier filter in Safari and Chrome,
- // and it is not everywhere. An unknown constraint name in this basic,
+ // voiceIsolation is the newer, heavier filter in Safari and Chrome —
+ // on a Mac it is Apple's own system Voice Isolation, reached through
+ // the browser. It is only ever asked for *off*, and never on. Asked
+ // on, it runs on top of the noise suppression above rather than
+ // instead of it, and two suppressors chewing one voice is not twice
+ // as clean: it is a growl with the keystrokes still coming through,
+ // which is what a call on 2026-09-17 came back as. The pipeline above
+ // is every browser's own default and is the one this app asks for.
+ //
+ // Off it still belongs here, because someone may have turned Voice
+ // Isolation on for the whole machine and studio audio has to be able
+ // to say no to it. An unknown constraint name in this basic,
// non-exact form is dropped rather than refused, so naming it costs
// nothing in a browser that has never heard of it.
function audioConstraints(prefs) {
@@ -369,8 +379,8 @@
echoCancellation: processed,
noiseSuppression: processed,
autoGainControl: processed,
- voiceIsolation: processed,
};
+ if (!processed) constraints.voiceIsolation = false;
if (prefs.mic) constraints.deviceId = { exact: prefs.mic };
return constraints;
}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index f3e6777..d139c84 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -506,6 +506,43 @@ func (rig *browserRig) signIn(t *testing.T, address string) (name, value string)
var sqidOnPage = regexp.MustCompile(`/m/([23456789a-z]{10})/join`)
var tokenOnPage = regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`)
+// enableWaitingRoom turns one meeting's waiting room on, as member A.
+//
+// It exists because the waiting room is no longer the default: meeting
+// access defaults ship it off, so a guest following a link is admitted
+// straight away and no admission queue is ever drawn. A scenario whose
+// subject IS the queue has to ask for one, or it silently stops testing
+// what it is named after — which is how the native gate's guest case came
+// to wait thirty seconds for an Admit button that was never going to be
+// there.
+func (rig *browserRig) enableWaitingRoom(t *testing.T, sqid string) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodPost, rig.origin+"/meetings/"+sqid+"/settings",
+ strings.NewReader(url.Values{"waiting_room": {"on"}}.Encode()))
+ if err != nil {
+ t.Fatalf("build settings request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", rig.origin)
+ req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieA})
+ // Not http.DefaultClient: it follows the 303 to the settings page and
+ // hands back that page's 200, so a refused save and an accepted one
+ // look identical. The redirect itself is the only honest evidence the
+ // handler accepted this.
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ res, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("POST meeting settings: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusSeeOther {
+ body, _ := io.ReadAll(res.Body)
+ t.Fatalf("POST meeting settings: %d\n%s", res.StatusCode, body)
+ }
+}
+
// createMeeting is member A starting a call and reading the link off the
// page it is shown on exactly once.
func (rig *browserRig) createMeeting(t *testing.T) (sqid, token string) {
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go
index 2f4d206..51d90e9 100644
--- a/internal/callstest/native_browser_test.go
+++ b/internal/callstest/native_browser_test.go
@@ -36,6 +36,10 @@ func nativeMacCallsBrowser(t *testing.T, guest, chat bool) {
if guest {
mode = "guest"
rig.ctl.SetAllowlist(memberA)
+ // This scenario's subject is the admission queue: a guest joins
+ // from the app and a member admits them from the browser. Access
+ // defaults ship the waiting room off, so it has to be asked for.
+ rig.enableWaitingRoom(t, room)
req, err := http.NewRequest("GET", rig.origin+"/j/"+token, nil)
if err != nil {
t.Fatal(err)
diff --git a/native/README.md b/native/README.md
index 1f4e9fa..d442e03 100644
--- a/native/README.md
+++ b/native/README.md
@@ -11,11 +11,78 @@ make native-mac
open native/build/Calls.app
```
-Paste a meeting or invitation URL. 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** and allow camera/microphone access.
-Creating and ending meetings, and replacing invitations, stay on the web for this milestone.
+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.
+
+Scheduling a meeting for later, ending one, and replacing invitations stay on
+the web for this milestone.
+
+**Devices** in the call opens the camera, microphone and speaker panel, and
+the audio readout beneath them.
+
+The camera is a picker on both platforms. Switching rests on the capturer
+feeding the same `RTCVideoSource`, so the `RTCVideoTrack` every peer
+connection already holds does not change and nothing renegotiates — the
+native counterpart of the browser's `replaceTrack`. Switches are serialised;
+the last pick wins.
+
+Audio is where the platforms part, and not by choice. WebRTC's iOS slices
+ship `RTCAudioDevice`, `RTCAudioSession` and three more audio headers; the
+macOS slice ships two, `RTCAudioSource` and `RTCAudioTrack`. There is no
+supported way to enumerate or select an audio device for WebRTC on macOS
+through this framework, and no way to supply a custom audio device module
+either, because the protocol is not in the slice. (It *is* in the Mac
+Catalyst slice, so this is a packaging boundary rather than a platform
+limit — but Catalyst would be a poor trade for a real AppKit app.)
+
+So on iPhone the microphone is a picker, and on Mac the panel reports which
+devices macOS has chosen — read from CoreAudio, which is what WebRTC's
+audio device module follows — and offers Sound settings. Naming them is
+worth doing on its own: the first thing someone needs when a call sounds
+wrong is to know what they are connected to.
+
+Choosing a Bluetooth microphone on iPhone puts the call on the hands-free
+profile, which is narrowband in both directions. That used to happen
+silently to every call the moment any headset connected, because
+`.allowBluetoothHFP` was set unconditionally; it is now set only while a
+Bluetooth input is the chosen one, and the panel says what it costs.
+
+The **audio readout** separates the two ways received audio goes wrong,
+which is the distinction a person reporting "it growled" cannot make:
+concealed samples are loss and sound like dropouts, while inserted and
+removed samples are the jitter buffer rescaling the signal to track the
+sender's clock — and a stretched voice is what a growl is. On macOS it is
+the only instrument there is, since a bad device cannot be ruled out by
+switching away from it.
+
+**Knocks** are announced when someone asks to be let in while the app is
+not frontmost. The admission queue is already on screen for a member who is
+looking at the call; this is for the member who is in another Space,
+another app, or has the phone in a pocket.
+
+These are **local** notifications, and that is the whole of what is
+possible here today: there is no APNs anywhere in this suite — no push
+key, no device token registration, no sender — so a member whose app is
+*closed* is not reached. That case needs server-side push and a decision
+about whether a host who is not in the meeting can be notified at all,
+since the waiting queue lives only in the hub's memory and reaches members
+over SSE.
+
+Someone who arrived while the queue was visible is never announced later,
+so tabbing away does not produce a pile of notifications about people who
+have been waiting a while. Only an arrival during an absence is news, and
+leaving the queue and knocking again is heard again.
Members can use **Share** in the call to retrieve the current invitation, copy
it, or open the system sharing menu. This needs the server's
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index bedb8e0..c358db5 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -56,6 +56,14 @@ struct Input: Decodable {
print("{\"invitationReady\":\(ready),\"invitationRefused\":\(refused)}")
fflush(stdout)
}
+ if command.hasPrefix("camera ") {
+ let id = String(command.dropFirst("camera ".count))
+ await call.useCamera(id)
+ let now = await call.currentCamera ?? ""
+ let note = await call.deviceNote
+ print("{\"camera\":\"\(now)\",\"cameraRefused\":\(note.isEmpty ? "false" : "true")}")
+ fflush(stdout)
+ }
if command == "share-snapshot" {
let saved = await preview?.saveInvitation(call: call) ?? false
print("{\"invitationSnapshotSaved\":\(saved)}")
diff --git a/native/apple/Sources/CallsInterop/SyntheticVideo.swift b/native/apple/Sources/CallsInterop/SyntheticVideo.swift
index 5f3742e..2eba113 100644
--- a/native/apple/Sources/CallsInterop/SyntheticVideo.swift
+++ b/native/apple/Sources/CallsInterop/SyntheticVideo.swift
@@ -7,6 +7,32 @@ import WebRTC
private var source: RTCVideoSource?
private var syntheticCapturer: RTCVideoCapturer?
private var task: Task<Void, Never>?
+ // Two cameras, differing only in frame size, so that a switch is
+ // something the far side can *see* rather than something this side
+ // merely claims. There is no camera in CI; what the gate can still hold
+ // is the plumbing — that a switch is serialised, that the track and its
+ // m-line survive it, and that frames keep arriving across it.
+ private var chosen = SyntheticVideo.first
+ nonisolated static let first = "synthetic-320"
+ nonisolated static let second = "synthetic-480"
+ private var width: Int { chosen == SyntheticVideo.second ? 480 : 320 }
+ private var height: Int { chosen == SyntheticVideo.second ? 360 : 240 }
+
+ nonisolated var cameras: [CameraDevice] {
+ [
+ CameraDevice(id: SyntheticVideo.first, name: "Synthetic 320x240"),
+ CameraDevice(id: SyntheticVideo.second, name: "Synthetic 480x360"),
+ ]
+ }
+ var currentCamera: String? { chosen }
+ func use(camera id: String) async throws {
+ guard cameras.contains(where: { $0.id == id }) else {
+ throw NSError(
+ domain: "Calls", code: 4,
+ userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."])
+ }
+ chosen = id
+ }
func start(source: RTCVideoSource) async throws {
self.source = source
syntheticCapturer = RTCVideoCapturer(delegate: source)
@@ -27,7 +53,7 @@ import WebRTC
guard let source, let syntheticCapturer else { return }
var pixel: CVPixelBuffer?
CVPixelBufferCreate(
- kCFAllocatorDefault, 320, 240, kCVPixelFormatType_32BGRA,
+ kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA,
[kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &pixel)
guard let pixel else { return }
CVPixelBufferLockBaseAddress(pixel, [])
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift
new file mode 100644
index 0000000..495036d
--- /dev/null
+++ b/native/apple/Sources/CallsKit/Audio.swift
@@ -0,0 +1,168 @@
+import AVFoundation
+import Foundation
+
+#if os(macOS)
+ import CoreAudio
+#endif
+
+#if os(iOS)
+ import WebRTC
+#endif
+
+/// One audio device the person can be on. `selectable` is the whole
+/// platform difference in one flag: on iPhone a route can be chosen, and
+/// on Mac it can only be reported.
+public struct AudioDevice: Identifiable, Hashable, Sendable {
+ public let id: String
+ public let name: String
+ public let selectable: Bool
+ public init(id: String, name: String, selectable: Bool) {
+ self.id = id
+ self.name = name
+ self.selectable = selectable
+ }
+}
+
+/// The audio devices a call is on, and — where the platform allows it —
+/// the ability to change them.
+///
+/// The asymmetry here is not a design choice. WebRTC's macOS slice ships
+/// two audio headers, RTCAudioSource and RTCAudioTrack; the iOS slices
+/// ship five, including RTCAudioDevice and RTCAudioSession. There is no
+/// supported way to enumerate or select an audio device for WebRTC on
+/// macOS through this framework, and no way to supply a custom audio
+/// device module either, because the protocol that would be implemented
+/// is not in the slice. (It *is* in the Mac Catalyst slice, so this is a
+/// packaging boundary rather than a platform impossibility — but a
+/// Catalyst build would be a poor trade for a real AppKit app.)
+///
+/// So on Mac this reports what macOS has chosen, read straight from
+/// CoreAudio, and offers the Sound settings pane to change it. Naming the
+/// device is worth doing on its own: the first thing a person needs when
+/// a call sounds wrong is to know what they are actually connected to,
+/// and until now the app could not tell them.
+@MainActor public final class AudioRoutes: ObservableObject {
+ @Published public private(set) var inputs: [AudioDevice] = []
+ @Published public private(set) var currentInput: String?
+ @Published public private(set) var outputName = ""
+ /// True where inputs can actually be chosen. The UI shows a picker when
+ /// it is set and a plain line of text when it is not, rather than a
+ /// disabled control that looks like something is broken.
+ public let selectable: Bool
+
+ public init() {
+ #if os(iOS)
+ selectable = true
+ #else
+ selectable = false
+ #endif
+ refresh()
+ }
+
+ public func refresh() {
+ #if os(iOS)
+ let session = AVAudioSession.sharedInstance()
+ inputs = (session.availableInputs ?? []).map {
+ AudioDevice(id: $0.uid, name: $0.portName, selectable: true)
+ }
+ currentInput = session.currentRoute.inputs.first?.uid
+ outputName = session.currentRoute.outputs.first?.portName ?? ""
+ #else
+ inputs = macDevice(input: true).map { [$0] } ?? []
+ currentInput = inputs.first?.id
+ outputName = macDevice(input: false)?.name ?? ""
+ #endif
+ }
+
+ #if os(iOS)
+ /// Chooses the microphone, and decides the Bluetooth question with it.
+ ///
+ /// `.allowBluetoothHFP` is what lets a Bluetooth microphone be used at
+ /// all, and it is also what drags the whole call down to the 8 kHz
+ /// hands-free profile the moment a headset connects — narrowband in
+ /// both directions, for a call that would otherwise be wideband. Left
+ /// on unconditionally, as it was, every pair of AirPods silently cost
+ /// the call its audio quality and nothing said so.
+ ///
+ /// So the category is rebuilt around the choice rather than set once:
+ /// HFP is allowed only while a Bluetooth input is the chosen one, and
+ /// otherwise the route is A2DP for output with the device's own
+ /// microphone, which is both wideband and usually the better
+ /// microphone anyway. Picking the headset is then an informed trade
+ /// someone made, not one made for them.
+ public func use(input id: String) throws {
+ let session = AVAudioSession.sharedInstance()
+ let port = (session.availableInputs ?? []).first { $0.uid == id }
+ guard let port else {
+ throw NSError(
+ domain: "Calls", code: 5,
+ userInfo: [NSLocalizedDescriptionKey: "That microphone is no longer available."])
+ }
+ let audio = RTCAudioSession.sharedInstance()
+ audio.lockForConfiguration()
+ defer { audio.unlockForConfiguration() }
+ try audio.setCategory(
+ AVAudioSession.Category.playAndRecord,
+ mode: AVAudioSession.Mode.videoChat,
+ options: AudioRoutes.options(bluetoothInput: AudioRoutes.isBluetooth(port)))
+ try session.setPreferredInput(port)
+ refresh()
+ }
+
+ /// Pure, and deliberately outside the actor: a rule about category
+ /// options is not state, and the test that holds it has no audio
+ /// session and no main actor to run on.
+ ///
+ /// True for the port types that only reach a microphone over the
+ /// hands-free profile. A2DP is output-only, so a headset used as a
+ /// microphone is always one of these.
+ public nonisolated static func isBluetooth(_ port: AVAudioSessionPortDescription) -> Bool {
+ port.portType == .bluetoothHFP
+ }
+
+ /// The category options for a call, given whether its microphone is a
+ /// Bluetooth one. Pure, so the test can hold the rule without an audio
+ /// session to configure.
+ public nonisolated static func options(bluetoothInput: Bool) -> AVAudioSession.CategoryOptions {
+ bluetoothInput
+ ? [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
+ : [.defaultToSpeaker, .allowBluetoothA2DP]
+ }
+ #endif
+
+ #if os(macOS)
+ /// The system's default input or output device, by name. CoreAudio is
+ /// the only way to ask: the WebRTC slice cannot say, and this is
+ /// precisely the device it will be using, because its audio device
+ /// module follows the system default.
+ private func macDevice(input: Bool) -> AudioDevice? {
+ var id = AudioDeviceID(0)
+ var size = UInt32(MemoryLayout<AudioDeviceID>.size)
+ var address = AudioObjectPropertyAddress(
+ mSelector: input
+ ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ guard
+ AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr,
+ id != kAudioObjectUnknown
+ else { return nil }
+ // Unmanaged, not CFString: CoreAudio writes a retained reference into
+ // this buffer, and pointing at a managed CFString both trips ARC's
+ // "object reference in a raw buffer" warning and leaves the +1
+ // unbalanced. takeRetainedValue consumes it exactly once.
+ var name: Unmanaged<CFString>?
+ var nameSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
+ var nameAddress = AudioObjectPropertyAddress(
+ mSelector: kAudioObjectPropertyName,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ guard
+ AudioObjectGetPropertyData(id, &nameAddress, 0, nil, &nameSize, &name) == noErr,
+ let value = name?.takeRetainedValue()
+ else { return nil }
+ return AudioDevice(id: String(id), name: value as String, selectable: false)
+ }
+ #endif
+}
diff --git a/native/apple/Sources/CallsKit/AudioHealth.swift b/native/apple/Sources/CallsKit/AudioHealth.swift
new file mode 100644
index 0000000..fbfc83b
--- /dev/null
+++ b/native/apple/Sources/CallsKit/AudioHealth.swift
@@ -0,0 +1,79 @@
+import Foundation
+
+/// What the received audio actually sounded like, as far as the counters
+/// can say — the distinction a person reporting "it growled" cannot make
+/// for us.
+///
+/// Two failures wear the same description. Loss is the decoder inventing
+/// audio that never arrived, and sounds like dropouts and robotic
+/// stuttering. Drift is the jitter buffer stretching or compressing the
+/// signal because the sender's clock and this device's clock disagree,
+/// and stretching a voice is what a growl *is*. Bytes arriving tells you
+/// neither.
+public struct AudioHealth: Equatable, Sendable {
+ /// Samples the decoder invented, against samples it received.
+ public let concealed: UInt64
+ public let total: UInt64
+ /// Samples the jitter buffer added or dropped to track the sender's
+ /// clock. Either one running is drift; which one says which way.
+ public let inserted: UInt64
+ public let removed: UInt64
+ public let jitterBufferMs: UInt64
+
+ public init(concealed: UInt64, total: UInt64, inserted: UInt64, removed: UInt64, jitterBufferMs: UInt64) {
+ self.concealed = concealed
+ self.total = total
+ self.inserted = inserted
+ self.removed = removed
+ self.jitterBufferMs = jitterBufferMs
+ }
+
+ public init(_ stats: [String: UInt64]) {
+ self.init(
+ concealed: stats["concealedSamples"] ?? 0,
+ total: stats["totalSamplesReceived"] ?? 0,
+ inserted: stats["insertedSamplesForDeceleration"] ?? 0,
+ removed: stats["removedSamplesForAcceleration"] ?? 0,
+ jitterBufferMs: stats["jitterBufferDelayMs"] ?? 0)
+ }
+
+ /// The fraction of received audio the decoder had to invent, 0 to 1.
+ /// Zero when nothing has been received, because no audio is not clean
+ /// audio and a reader should not be told otherwise by a division.
+ public var concealmentRate: Double {
+ total == 0 ? 0 : Double(concealed) / Double(total)
+ }
+
+ /// The fraction of received audio the jitter buffer stretched or
+ /// squeezed. Both directions count: what matters is that the signal was
+ /// rescaled, not which way it went.
+ public var driftRate: Double {
+ total == 0 ? 0 : Double(inserted + removed) / Double(total)
+ }
+
+ /// Nothing received yet. Distinct from healthy, and said differently.
+ public var silent: Bool { total == 0 }
+
+ /// One plain sentence, for the person who has opened this panel because
+ /// something sounds wrong. Thresholds are deliberately blunt: this is a
+ /// pointer towards which half of the problem to look at, not a
+ /// measurement anyone should tune against.
+ ///
+ /// Drift is named first when both are up. Loss is common, usually
+ /// transient and widely understood; sustained drift is rarer, is what
+ /// makes a voice sound wrong rather than broken, and is the one nobody
+ /// thinks to look for.
+ public var summary: String {
+ if silent { return "No audio received yet." }
+ let loss = Int((concealmentRate * 100).rounded())
+ let drift = Int((driftRate * 100).rounded())
+ if driftRate >= 0.02 {
+ return "Audio is being resampled to track the sender's clock (\(drift)%). "
+ + "That is what makes a voice sound slowed or growling."
+ }
+ if concealmentRate >= 0.05 {
+ return "\(loss)% of audio did not arrive and was filled in. That sounds like dropouts."
+ }
+ return "Audio is arriving cleanly (\(loss)% filled in, \(drift)% resampled)."
+ }
+}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index aed8006..55909c3 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -41,6 +41,20 @@ private final class Events: NSObject, BridgeListenerProtocol {
@Published public private(set) var invitationURL: URL?
@Published public private(set) var invitationError = ""
@Published public private(set) var loadingInvitation = false
+ /// What the last device change had to say, and usually nothing.
+ @Published public private(set) var deviceNote = ""
+ /// The audio-health readout, once a call has been up long enough for
+ /// one. Nil means no sample yet, which is not the same as healthy.
+ @Published public private(set) var audioHealth: AudioHealth?
+ /// The camera in use. Published rather than read through the capture on
+ /// demand, because a picker bound to a plain computed property never
+ /// learns that a switch finished.
+ @Published public private(set) var currentCamera: String?
+ /// The routes this platform can report, and on iPhone choose between.
+ public let audio = AudioRoutes()
+ private var cameraWork: Task<Void, Never>?
+ private var cameraSwitches = 0
+ private let knocks = KnockWatch()
fileprivate var epoch = UUID()
private var bridge: BridgeClient?
private var events: Events?
@@ -84,6 +98,9 @@ private final class Events: NSObject, BridgeListenerProtocol {
try await media.prepare(capture: capture)
guard epoch == joiningEpoch, bridge === client else { throw CancellationError() }
localTrack = media.localVideo
+ currentCamera = media.currentCamera
+ audio.refresh()
+ KnockNotice.requestPermission()
try client.start()
}
public func close() {
@@ -100,6 +117,12 @@ private final class Events: NSObject, BridgeListenerProtocol {
invitationURL = nil
invitationError = ""
loadingInvitation = false
+ deviceNote = ""
+ audioHealth = nil
+ currentCamera = nil
+ knocks.reset()
+ cameraWork?.cancel()
+ cameraWork = nil
if let old { DispatchQueue.global().async { old.close() } }
}
public func leave() async {
@@ -117,6 +140,71 @@ private final class Events: NSObject, BridgeListenerProtocol {
cameraOff.toggle()
media.localVideo?.isEnabled = !cameraOff
}
+
+ // ---------------------------------------------------------------
+ // Devices
+ // ---------------------------------------------------------------
+
+ /// The cameras available, and the one in use. Read through the running
+ /// capture rather than from AVFoundation directly, so what the picker
+ /// offers is what the call could actually switch to.
+ public var cameras: [CameraDevice] { media.cameras }
+
+ /// Switches camera mid-call. Serialised: two picks are two capture
+ /// restarts on one device, and run at once the survivor is whichever
+ /// session happened to open last, which is not the one the person
+ /// chose. The counter decides who wins; the queue decides that only
+ /// one runs.
+ public func useCamera(_ id: String) async {
+ cameraSwitches += 1
+ let mine = cameraSwitches
+ let previous = cameraWork
+ let work = Task { @MainActor [weak self] in
+ await previous?.value
+ guard let self, mine == self.cameraSwitches else { return }
+ do {
+ try await self.media.use(camera: id)
+ self.deviceNote = ""
+ } catch is CancellationError {
+ } catch {
+ // The call is left without a picture rather than silently on the
+ // old camera: the old session has to be released before the new
+ // device can be opened. Saying so is the least this can do.
+ self.deviceNote = error.localizedDescription
+ }
+ // Read back rather than assume: a refused switch left the capture
+ // wherever it actually is, and the picker has to follow the camera
+ // and not the intention.
+ self.currentCamera = self.media.currentCamera
+ }
+ cameraWork = work
+ await work.value
+ }
+
+ /// Records something a device change had to say. The panel has no
+ /// place of its own to put an error, and a route that would not change
+ /// belongs beside the control that would not change it.
+ public func noteDevice(_ text: String) { deviceNote = text }
+
+ /// Polls the audio-health counters while a call is up.
+ ///
+ /// This exists because of a call on 2026-09-17 that came back as an
+ /// unintelligible growl, and because nothing in this app recorded
+ /// anything about it — no log, no counters, no artefact. A reproduction
+ /// would have produced nothing either. On macOS it is also the *only*
+ /// instrument available, since the framework's macOS slice has no audio
+ /// device API and a bad device cannot be ruled out by switching away
+ /// from it.
+ ///
+ /// Five seconds because this is a readout a person glances at when
+ /// something sounds wrong, not a time series.
+ public func watchAudio() async {
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+ guard !Task.isCancelled, bridge != nil else { return }
+ audioHealth = AudioHealth(await media.stats())
+ }
+ }
public func retryJoin() { send(type: "join", to: "", gen: 0, payload: "") }
public func loadInvitation() async {
guard let client = bridge, !loadingInvitation else { return }
@@ -177,6 +265,9 @@ private final class Events: NSObject, BridgeListenerProtocol {
case "state":
guard let state = event.state else { return }
self.state = state
+ // Only a member is sent a waiting list at all (spec §4.3), so this
+ // needs no check of its own for who is allowed to be told.
+ KnockNotice.announce(knocks.arrivals(waiting: state.waiting, visible: KnockNotice.appIsFrontmost))
switch state.phase {
case "admitted": status = "In the meeting"
case "waiting": status = "Waiting to be let in"
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift
index f0e80c9..67d272d 100644
--- a/native/apple/Sources/CallsKit/CallsLobby.swift
+++ b/native/apple/Sources/CallsKit/CallsLobby.swift
@@ -38,12 +38,25 @@ import SwiftUI
}
Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered)
} else if model.signedIn {
- Label("Meeting \(model.room)", systemImage: "video")
- .font(.headline).textSelection(.enabled)
+ if model.room.isEmpty {
+ Label("Signed in to \(model.host)", systemImage: "person.crop.circle.badge.checkmark")
+ .font(.headline).textSelection(.enabled)
+ } else {
+ 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)
- Button("Join meeting", systemImage: "video.fill", action: model.join)
- .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ // 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.
+ if !model.room.isEmpty {
+ Button("Join meeting", systemImage: "video.fill", action: model.join)
+ .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ .accessibilityIdentifier("joinMeeting")
+ }
+ if model.canStart { startButton }
+ if model.starting { ProgressView("Starting a meeting") }
Button("Choose another meeting") { model.signedIn = false }
.buttonStyle(.bordered)
#if os(iOS)
@@ -53,7 +66,7 @@ import SwiftUI
} else {
VStack(alignment: .leading, spacing: 10) {
Text("Meeting link").font(.headline)
- TextField("Meeting or invitation link", text: $model.address)
+ TextField("Meeting link, or your Calls address", text: $model.address)
.textFieldStyle(.roundedBorder)
.accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink")
#if os(iOS)
@@ -64,8 +77,10 @@ import SwiftUI
Button("Continue", systemImage: "arrow.right", action: model.connect)
.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
.disabled(model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- Text("Use the meeting or invitation link shared with you.")
- .font(.callout).foregroundStyle(.secondary)
+ Text(
+ "Use the meeting or invitation link shared with you — or your own Calls address, to start a meeting of your own."
+ )
+ .font(.callout).foregroundStyle(.secondary)
}
if !model.error.isEmpty {
Label(model.error, systemImage: "exclamationmark.circle.fill")
@@ -88,9 +103,30 @@ import SwiftUI
.tint(CallsAppearance.accent)
}
+ // 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
+ // problem and is a shape problem.
+ @ViewBuilder private var startButton: some View {
+ // Two branches rather than a ternary per modifier: buttonStyle's
+ // return type differs between the two, so the choice has to be made
+ // where whole views can differ and not inside one.
+ if model.room.isEmpty {
+ start("Start meeting").buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ } else {
+ start("Start another meeting").buttonStyle(.bordered)
+ }
+ }
+
+ private func start(_ title: LocalizedStringKey) -> some View {
+ Button(title, systemImage: "video.badge.plus", action: model.startMeeting)
+ .accessibilityIdentifier("startMeeting")
+ .disabled(model.starting)
+ }
+
private var heading: LocalizedStringKey {
if model.authenticating { return "One more step." }
- if model.signedIn { return "Ready to join" }
+ if model.signedIn { return model.room.isEmpty ? "Ready when you are" : "Ready to join" }
return "Meet face to face."
}
@@ -99,7 +135,9 @@ import SwiftUI
return
"Continue in your browser, then confirm that the code matches. We’ll continue here automatically."
}
- if model.signedIn { return "Join when you’re ready." }
+ if model.signedIn {
+ return model.room.isEmpty ? "Start a meeting and share the link from inside it." : "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 e6c088f..555ba08 100644
--- a/native/apple/Sources/CallsKit/CallsModel.swift
+++ b/native/apple/Sources/CallsKit/CallsModel.swift
@@ -19,12 +19,28 @@ import Foundation
private var callEpoch = UUID()
@Published public var signedIn = false
@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.
+ @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() {}
+ /// The instance's host, for a line that says where this app is signed
+ /// in. The scheme is dropped: it is https for everyone but a developer
+ /// on loopback, and naming it would only make the line longer.
+ public var host: String {
+ URLComponents(string: origin)?.host ?? origin
+ }
+
public func openMeetingURL(_ url: URL) {
guard !joining else {
error = "Leave the current meeting before opening another."
@@ -47,6 +63,8 @@ import Foundation
}
cancelSignIn()
signedIn = false
+ canStart = false
+ room = ""
error = ""
address = meeting.absoluteString
// Let the person review the meeting address before starting authorization.
@@ -96,6 +114,10 @@ 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
signedIn = true
authenticating = false
signin = nil
@@ -119,6 +141,7 @@ import Foundation
}
public func cancelSignIn() {
authEpoch = UUID()
+ starting = false
authentication?.cancel()
authentication = nil
authenticating = false
@@ -134,6 +157,46 @@ import Foundation
let value: String
}
}
+ /// Creates a meeting on the signed-in instance and joins it. The
+ /// server decides the title, in its own zone, so a Mac in another one
+ /// cannot name a meeting an hour it is not.
+ ///
+ /// The identifier is kept after the call ends rather than cleared, so
+ /// leaving and rejoining the meeting you started is one button and not
+ /// a second meeting. Starting again simply replaces it.
+ public func startMeeting() {
+ guard !starting, !joining else { return }
+ guard canStart else {
+ error = "Sign in with your Calls address to start a meeting."
+ return
+ }
+ starting = true
+ error = ""
+ let origin = self.origin, cookies = self.cookies
+ let operation = callEpoch
+ Task {
+ do {
+ let sqid = try await Task.detached {
+ var error: NSError?
+ let sqid = BridgeStartMeeting(origin, cookies, &error)
+ if let error { throw error }
+ return sqid
+ }.value
+ // A leave or a second start while the server was answering: the
+ // meeting exists and is simply not the one this app is on. It is
+ // left alone rather than ended, because ending someone else's
+ // newer meeting is the worse mistake.
+ guard operation == callEpoch else { return }
+ starting = false
+ room = sqid
+ join()
+ } catch {
+ guard operation == callEpoch else { return }
+ self.error = error.localizedDescription
+ starting = false
+ }
+ }
+ }
public func join() {
callEpoch = UUID()
let operation = callEpoch
@@ -152,6 +215,7 @@ import Foundation
}
public func leave() {
callEpoch = UUID()
+ starting = false
let operation = callEpoch
cancelSignIn()
Task {
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift
index 533d30c..8f4e51f 100644
--- a/native/apple/Sources/CallsKit/Capture.swift
+++ b/native/apple/Sources/CallsKit/Capture.swift
@@ -2,16 +2,53 @@ import AVFoundation
import Foundation
import WebRTC
+/// One camera the person can choose, named the way a menu wants it.
+///
+/// The id is `AVCaptureDevice.uniqueID`, which survives unplugging and
+/// replugging the same camera, so a remembered choice means the same
+/// device next time rather than whatever now sits at that index.
+public struct CameraDevice: Identifiable, Hashable, Sendable {
+ public let id: String
+ public let name: String
+ public init(id: String, name: String) {
+ self.id = id
+ self.name = name
+ }
+}
+
// Capture is injected separately from signaling and rendering. App targets
// provide the platform camera; the interop executable provides synthetic frames.
@MainActor public protocol VideoCapture: AnyObject {
func start(source: RTCVideoSource) async throws
func stop()
+
+ /// The cameras this capture can offer, and which one it is on. A capture
+ /// with nothing to choose between answers an empty list, and the picker
+ /// does not appear — which is what the interop executable's synthetic
+ /// frames want, and why these carry defaults rather than being required.
+ var cameras: [CameraDevice] { get }
+ var currentCamera: String? { get }
+ func use(camera id: String) async throws
+}
+
+extension VideoCapture {
+ public var cameras: [CameraDevice] { [] }
+ public var currentCamera: String? { nil }
+ public func use(camera id: String) async throws {}
}
@MainActor final class CameraCapture: VideoCapture {
private var capturer: RTCCameraVideoCapturer?
+ private var source: RTCVideoSource?
private var epoch = UUID()
+ private(set) var currentCamera: String?
+
+ var cameras: [CameraDevice] {
+ RTCCameraVideoCapturer.captureDevices().map {
+ CameraDevice(id: $0.uniqueID, name: $0.localizedName)
+ }
+ }
+
func start(source: RTCVideoSource) async throws {
let preparing = epoch
for kind in [AVMediaType.video, .audio] {
@@ -32,7 +69,54 @@ import WebRTC
#else
let preferredDevice = devices.first
#endif
- guard let device = preferredDevice ?? devices.first,
+ guard let device = preferredDevice ?? devices.first else {
+ throw NSError(
+ domain: "Calls", code: 3, userInfo: [NSLocalizedDescriptionKey: "No camera is available."])
+ }
+ self.source = source
+ let capture = RTCCameraVideoCapturer(delegate: source)
+ capturer = capture
+ try await run(capture, device: device, preparing: preparing)
+ }
+
+ /// Switches the camera without touching the track.
+ ///
+ /// The capturer keeps delivering into the same RTCVideoSource, so the
+ /// RTCVideoTrack every peer connection is already sending does not
+ /// change and no renegotiation happens — the native counterpart of the
+ /// browser's replaceTrack, and for the same reason: an m-line that
+ /// exists and keeps existing is the one thing perfect negotiation never
+ /// has to hear about.
+ ///
+ /// A failed switch leaves the call without a picture rather than
+ /// silently on the old camera, because the old capture session has to
+ /// be stopped before the new device can be opened — two AVCaptureDevices
+ /// cannot hold the same camera, and on a Mac they often *are* the same
+ /// camera behind two names. The error says so; the picker puts itself
+ /// back.
+ func use(camera id: String) async throws {
+ guard let capture = capturer, source != nil else { return }
+ guard id != currentCamera else { return }
+ guard let device = RTCCameraVideoCapturer.captureDevices().first(where: { $0.uniqueID == id })
+ else {
+ throw NSError(
+ domain: "Calls", code: 4,
+ userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."])
+ }
+ epoch = UUID()
+ let preparing = epoch
+ await capture.stopCapture()
+ guard preparing == epoch else { throw CancellationError() }
+ try await run(capture, device: device, preparing: preparing)
+ }
+
+ /// The half start and use share: pick the format nearest 640 wide, cap
+ /// the frame rate at 30, and start. Held together so a camera chosen
+ /// mid-call is opened exactly the way the one chosen at join was.
+ private func run(_ capture: RTCCameraVideoCapturer, device: AVCaptureDevice, preparing: UUID)
+ async throws
+ {
+ guard
let format = RTCCameraVideoCapturer.supportedFormats(for: device).min(by: {
abs(Int(CMVideoFormatDescriptionGetDimensions($0.formatDescription).width) - 640)
< abs(Int(CMVideoFormatDescriptionGetDimensions($1.formatDescription).width) - 640)
@@ -41,8 +125,6 @@ import WebRTC
throw NSError(
domain: "Calls", code: 3, userInfo: [NSLocalizedDescriptionKey: "No camera is available."])
}
- let capture = RTCCameraVideoCapturer(delegate: source)
- capturer = capture
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
capture.startCapture(
with: device, format: format,
@@ -56,11 +138,14 @@ import WebRTC
await capture.stopCapture()
throw CancellationError()
}
+ currentCamera = device.uniqueID
}
func stop() {
epoch = UUID()
capturer?.stopCapture()
capturer = nil
+ source = nil
+ currentCamera = nil
}
}
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift
new file mode 100644
index 0000000..ab3c609
--- /dev/null
+++ b/native/apple/Sources/CallsKit/DevicePanel.swift
@@ -0,0 +1,154 @@
+import SwiftUI
+
+#if os(macOS)
+ import AppKit
+#endif
+
+/// The camera, microphone and speaker a call is on — chosen where the
+/// platform allows it, reported where it does not.
+///
+/// One view for both apps, because the thing a person wants here is the
+/// same on a Mac and an iPhone even though what they can do about it is
+/// not. On iPhone every row is a choice. On Mac the camera is a choice
+/// and audio is a statement of fact with a way out to Sound settings,
+/// because WebRTC's macOS slice carries no audio device API at all.
+///
+/// The audio readout at the bottom is the reason this panel opens at
+/// all, most of the time: someone has noticed the call sounds wrong. It
+/// says which half of "wrong" the counters are seeing, in a sentence,
+/// rather than making them guess between a bad network and a bad device.
+@MainActor public struct DevicePanel: View {
+ @ObservedObject private var call: Call
+ @ObservedObject private var audio: AudioRoutes
+ @State private var camera: String?
+
+ public init(call: Call) {
+ self.call = call
+ self.audio = call.audio
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ if call.cameras.count > 1 {
+ picker(
+ "Camera", selection: $camera,
+ options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker")
+ } else if let only = call.cameras.first {
+ row("Camera", only.name)
+ }
+
+ #if os(iOS)
+ if audio.inputs.count > 1 {
+ microphonePicker
+ } else if let only = audio.inputs.first {
+ row("Microphone", only.name)
+ }
+ if !audio.outputName.isEmpty { row("Speaker", audio.outputName) }
+ #else
+ macAudio
+ #endif
+
+ if !call.deviceNote.isEmpty {
+ Label(call.deviceNote, systemImage: "exclamationmark.circle.fill")
+ .font(.callout).foregroundStyle(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityLabel("Device problem: \(call.deviceNote)")
+ }
+
+ Divider()
+ audioReadout
+ }
+ .padding(16)
+ .onAppear {
+ camera = call.currentCamera
+ audio.refresh()
+ }
+ // The picker follows the camera, not the intention: a switch that was
+ // refused leaves the capture where it was, and the control has to go
+ // back rather than keep showing a device the call is not on.
+ .onChange(of: call.currentCamera) { _, now in camera = now }
+ .onChange(of: camera) { was, now in
+ guard let now, now != was, now != call.currentCamera else { return }
+ Task { await call.useCamera(now) }
+ }
+ }
+
+ #if os(iOS)
+ private var microphonePicker: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ picker(
+ "Microphone", selection: Binding(
+ get: { audio.currentInput },
+ set: { id in
+ guard let id else { return }
+ do { try audio.use(input: id) } catch { call.noteDevice(error.localizedDescription) }
+ }),
+ options: audio.inputs.map { ($0.id, $0.name) }, identifier: "microphonePicker")
+ // Said before the choice rather than after it: the cost of a
+ // Bluetooth microphone is that the whole call drops to the
+ // hands-free profile, which is narrowband both ways. Someone who
+ // picks it should be picking it knowingly.
+ Text(
+ "A Bluetooth microphone puts the call on the hands-free profile, which is lower quality in both directions."
+ )
+ .font(.footnote).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ #endif
+
+ #if os(macOS)
+ private var macAudio: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if let input = audio.inputs.first { row("Microphone", input.name) }
+ if !audio.outputName.isEmpty { row("Speaker", audio.outputName) }
+ Text("Calls uses whichever microphone and speaker macOS is set to.")
+ .font(.footnote).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button("Open Sound settings", systemImage: "gearshape") {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.Sound-Settings.extension") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ .buttonStyle(.bordered)
+ .accessibilityIdentifier("openSoundSettings")
+ }
+ }
+ #endif
+
+ private var audioReadout: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Audio health").font(.headline)
+ if let health = call.audioHealth {
+ Text(health.summary)
+ .font(.callout).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("audioHealth")
+ } else {
+ Text("Measuring…").font(.callout).foregroundStyle(.secondary)
+ .accessibilityIdentifier("audioHealth")
+ }
+ }
+ }
+
+ private func row(_ label: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label).font(.headline)
+ Text(value).font(.callout).foregroundStyle(.secondary).textSelection(.enabled)
+ }.accessibilityElement(children: .combine)
+ }
+
+ private func picker(
+ _ label: String, selection: Binding<String?>, options: [(String, String)], identifier: String
+ ) -> some View {
+ Picker(label, selection: selection) {
+ ForEach(options, id: \.0) { option in
+ Text(option.1).tag(Optional(option.0))
+ }
+ }
+ .accessibilityIdentifier(identifier)
+ #if os(macOS)
+ .pickerStyle(.menu)
+ #endif
+ }
+}
diff --git a/native/apple/Sources/CallsKit/Knocks.swift b/native/apple/Sources/CallsKit/Knocks.swift
new file mode 100644
index 0000000..1577912
--- /dev/null
+++ b/native/apple/Sources/CallsKit/Knocks.swift
@@ -0,0 +1,101 @@
+import Foundation
+import UserNotifications
+
+#if os(iOS)
+ import UIKit
+#else
+ import AppKit
+#endif
+
+/// Notices when someone new is waiting to be let in, and says so once.
+///
+/// The admission queue is already on screen for a member who is looking at
+/// the call. This is for the member who is not: another Space, another
+/// app, a phone in a pocket. Without it, someone knocking on a call whose
+/// host has tabbed away waits until the host happens to look.
+///
+/// The decision of *whether* to announce is kept apart from the act of
+/// announcing, because the decision is the part with rules in it — once
+/// per person, never for someone already announced, forgotten when they
+/// stop waiting so a re-knock is heard again — and the act needs a
+/// notification centre and a real bundle.
+@MainActor public final class KnockWatch {
+ /// Peers already announced and still waiting. Kept rather than a plain
+ /// count so that one person leaving the queue and another arriving is
+ /// one announcement and not none.
+ private(set) var announced: Set<String> = []
+
+ public init() {}
+
+ /// Returns the people worth announcing, and records them.
+ ///
+ /// `visible` is the whole of the "don't shout at someone already
+ /// looking" rule: when the queue is on screen, arrivals are recorded as
+ /// though announced, so tabbing away afterwards does not then produce a
+ /// pile of notifications about people who have been waiting for a
+ /// while. Only someone who arrives *while* you are away is news.
+ public func arrivals(waiting: [RosterPeer], visible: Bool) -> [RosterPeer] {
+ let present = Set(waiting.map(\.peer))
+ // Someone who left the queue is forgotten, so that knocking again
+ // after being let in — or after giving up — is heard again.
+ announced.formIntersection(present)
+ let fresh = waiting.filter { !announced.contains($0.peer) }
+ announced.formUnion(present)
+ return visible ? [] : fresh
+ }
+
+ /// Dropped when a call ends: a queue from a finished meeting has
+ /// nothing to say about the next one.
+ public func reset() { announced = [] }
+}
+
+/// The act, as opposed to the decision. Local notifications only: there
+/// is no APNs anywhere in this suite — no push key, no device tokens, no
+/// sender — so this reaches a member whose app is running and not
+/// frontmost, and not one whose app is closed. That second case is real
+/// and is not solved here.
+@MainActor public enum KnockNotice {
+ /// True when the app is in front, and the queue therefore already
+ /// visible to anyone looking at it.
+ public static var appIsFrontmost: Bool {
+ #if os(iOS)
+ return UIApplication.shared.applicationState == .active
+ #else
+ return NSApp?.isActive ?? false
+ #endif
+ }
+
+ /// Asks once, at join, rather than at the first knock: a permission
+ /// sheet arriving at the same moment as the thing it is about is a
+ /// sheet that gets dismissed to get at the thing.
+ public static func requestPermission() {
+ guard let centre = centre else { return }
+ centre.requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ }
+
+ public static func announce(_ people: [RosterPeer]) {
+ guard let centre = centre, !people.isEmpty else { return }
+ for person in people {
+ let content = UNMutableNotificationContent()
+ content.title = "Someone is waiting to join"
+ // The person's own name, which came from the server's roster and is
+ // someone else's text — so it goes in the body as data and never
+ // into a format string that could be read as anything else.
+ content.body = person.name
+ content.sound = .default
+ centre.add(
+ UNNotificationRequest(
+ identifier: "knock-" + person.peer, content: content, trigger: nil))
+ }
+ }
+
+ /// Nil outside a real application bundle. UNUserNotificationCenter
+ /// raises rather than returning an error when there is no bundle
+ /// identifier to register against, which is every unit test and the
+ /// interop executable — neither of which should take the app down for
+ /// want of a notification nobody would see.
+ private static var centre: UNUserNotificationCenter? {
+ guard Bundle.main.bundleIdentifier != nil else { return nil }
+ return UNUserNotificationCenter.current()
+ }
+}
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index 04ac523..93a201c 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -8,6 +8,7 @@
@State private var showingChat: Bool
@State private var selectedPeer: String?
@State private var readChatCount = 0
+ @State private var showingDevices = false
@StateObject private var chatWindow = MacChatWindow()
public init(
@@ -50,6 +51,19 @@
}
HStack(alignment: .top, spacing: 16) {
stage.frame(maxWidth: .infinity, maxHeight: .infinity)
+ if showingDevices {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Devices").font(.headline)
+ Spacer()
+ Button("Hide devices", systemImage: "xmark") { showingDevices = false }
+ .labelStyle(.iconOnly).buttonStyle(.borderless).help("Hide devices")
+ }.padding(.horizontal, 16).padding(.top, 16)
+ DevicePanel(call: call)
+ Spacer(minLength: 0)
+ }.frame(width: 300)
+ .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
+ }
if showingChat {
VStack(spacing: 14) {
HStack {
@@ -80,6 +94,7 @@
.onAppear {
if showingChat { readChatCount = call.receivedChatCount }
}
+ .task { await call.watchAudio() }
.onChange(of: call.receivedChatCount) { _, count in
if showingChat || chatWindow.isOpen { readChatCount = count }
}
@@ -159,6 +174,10 @@
emphasized: call.cameraOff)
)
.help(call.cameraOff ? "Turn camera on" : "Turn camera off")
+ Button("Devices", systemImage: "slider.horizontal.3") { showingDevices.toggle() }
+ .buttonStyle(CallActionStyle(color: .teal, emphasized: showingDevices))
+ .help("Choose your camera, and see which microphone and speaker this call is on")
+ .accessibilityIdentifier("devices")
MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
.help("Share meeting link")
Spacer()
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index 12224f2..46e73ca 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -39,14 +39,25 @@ import WebRTC
let audio = RTCAudioSession.sharedInstance()
audio.lockForConfiguration()
defer { audio.unlockForConfiguration() }
+ // Not .allowBluetoothHFP: see AudioRoutes.options. At join nobody
+ // has chosen a Bluetooth microphone, so the call starts wideband
+ // and only drops to the hands-free profile if someone asks for a
+ // Bluetooth input by name.
try audio.setCategory(
AVAudioSession.Category.playAndRecord,
mode: AVAudioSession.Mode.videoChat,
- options: [.defaultToSpeaker, .allowBluetoothHFP])
+ options: AudioRoutes.options(bluetoothInput: false))
// WebRTC owns activation and interruption recovery as peers start/stop.
}
#endif
}
+ /// The cameras the running capture can offer, and the one it is on.
+ /// Empty before prepare, and empty for the interop executable's
+ /// synthetic frames, which is how the picker knows not to appear.
+ var cameras: [CameraDevice] { capture?.cameras ?? [] }
+ var currentCamera: String? { capture?.currentCamera }
+ func use(camera id: String) async throws { try await capture?.use(camera: id) }
+
func closePeers() {
for (_, p) in peers { p.close() }
peers.removeAll()
@@ -79,10 +90,37 @@ import WebRTC
}
func receive(_ message: Message) { if let from = message.from { peers[from]?.receive(message) } }
func updateICE(_ config: ICEConfig) { for p in peers.values { p.updateICE(config) } }
+ /// The five audio-health fields, gathered alongside the byte counts.
+ ///
+ /// Bytes say audio arrived; they say nothing about what it sounded like.
+ /// These five separate the two ways received audio goes wrong, which is
+ /// the distinction a person reporting "it growled" cannot make for us:
+ ///
+ /// - `concealedSamples` against `totalSamplesReceived` is loss. The
+ /// decoder invented that fraction of the audio because nothing
+ /// arrived to decode.
+ /// - `insertedSamplesForDeceleration` and
+ /// `removedSamplesForAcceleration` are clock drift. The jitter
+ /// buffer is stretching or compressing the signal to keep up with a
+ /// sender whose clock runs at a different rate than this device's —
+ /// and stretching a voice is what a growl *is*.
+ ///
+ /// On macOS this is the only instrument there is. The framework's macOS
+ /// slice ships no audio device API at all, so a bad device cannot be
+ /// ruled out by switching away from it the way it can on iPhone; the
+ /// counters are what is left. See DESIGN.md.
+ static let audioHealthFields = [
+ "concealedSamples", "totalSamplesReceived",
+ "insertedSamplesForDeceleration", "removedSamplesForAcceleration",
+ ]
+
func stats() async -> [String: UInt64] {
var values: [String: UInt64] = [
"framesDecoded": 0, "audioBytesReceived": 0, "videoBytesSent": 0, "audioBytesSent": 0,
"peerConnections": UInt64(peers.count),
+ "concealedSamples": 0, "totalSamplesReceived": 0,
+ "insertedSamplesForDeceleration": 0, "removedSamplesForAcceleration": 0,
+ "jitterBufferDelayMs": 0,
]
for peer in peers.values {
guard let pc = peer.pc else { continue }
@@ -99,6 +137,16 @@ import WebRTC
if kind == "audio" {
values["audioBytesReceived", default: 0] +=
(stat.values["bytesReceived"] as? NSNumber)?.uint64Value ?? 0
+ for field in MediaEngine.audioHealthFields {
+ values[field, default: 0] += (stat.values[field] as? NSNumber)?.uint64Value ?? 0
+ }
+ // jitterBufferDelay is seconds as a double, and is cumulative
+ // against jitterBufferEmittedCount rather than a gauge. It is
+ // carried in milliseconds because everything else here is an
+ // integer, and a sub-millisecond buffer is not a fact anyone
+ // reading this is going to act on.
+ let delay = (stat.values["jitterBufferDelay"] as? NSNumber)?.doubleValue ?? 0
+ values["jitterBufferDelayMs", default: 0] += UInt64((delay * 1000).rounded())
}
}
if stat.type == "outbound-rtp" {
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
new file mode 100644
index 0000000..de58c53
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
@@ -0,0 +1,123 @@
+import XCTest
+
+@testable import CallsKit
+
+/// AudioHealth is the one part of the device work with real logic in it,
+/// and the one that has to be right: it is what a person reads when a
+/// call sounds wrong, and on macOS it is the only instrument there is.
+final class AudioHealthTests: XCTestCase {
+ func testNothingReceivedIsSaidPlainlyAndNeverDividedBy() {
+ let health = AudioHealth(concealed: 0, total: 0, inserted: 0, removed: 0, jitterBufferMs: 0)
+ XCTAssertTrue(health.silent)
+ // Not 0% clean: no audio is not clean audio, and a rate computed from
+ // an empty denominator would say the opposite of the truth.
+ XCTAssertEqual(health.concealmentRate, 0)
+ XCTAssertEqual(health.driftRate, 0)
+ XCTAssertEqual(health.summary, "No audio received yet.")
+ }
+
+ func testDriftIsNamedAheadOfLossWhenBothAreUp() {
+ // The growl case. Loss is common, transient and widely understood;
+ // sustained drift is rarer, is what makes a voice sound *wrong*
+ // rather than broken, and is the one nobody thinks to look for. With
+ // both raised, the rarer diagnosis is the useful one to lead with.
+ let health = AudioHealth(
+ concealed: 20_000, total: 100_000, inserted: 4_000, removed: 1_000, jitterBufferMs: 300)
+ XCTAssertEqual(health.driftRate, 0.05, accuracy: 0.0001)
+ XCTAssertEqual(health.concealmentRate, 0.20, accuracy: 0.0001)
+ XCTAssertTrue(health.summary.contains("resampled"), health.summary)
+ XCTAssertTrue(health.summary.contains("growling"), health.summary)
+ }
+
+ func testLossAloneReadsAsDropoutsAndNotAsDrift() {
+ let health = AudioHealth(
+ concealed: 10_000, total: 100_000, inserted: 0, removed: 0, jitterBufferMs: 40)
+ XCTAssertTrue(health.summary.contains("did not arrive"), health.summary)
+ XCTAssertTrue(health.summary.contains("dropouts"), health.summary)
+ XCTAssertFalse(health.summary.contains("growling"), health.summary)
+ }
+
+ func testAHealthyCallSaysSoWithItsNumbers() {
+ let health = AudioHealth(
+ concealed: 500, total: 100_000, inserted: 100, removed: 100, jitterBufferMs: 30)
+ XCTAssertFalse(health.silent)
+ XCTAssertTrue(health.summary.contains("cleanly"), health.summary)
+ // The numbers ride along even when the verdict is good: someone
+ // comparing two calls needs them, and rounding to zero is honest at
+ // this resolution.
+ XCTAssertTrue(health.summary.contains("0%"), health.summary)
+ }
+
+ func testDriftCountsBothDirections() {
+ // Inserting and removing are the same fault seen from two sides — the
+ // signal was rescaled either way. A reading that counted only one
+ // would miss a sender whose clock runs fast.
+ let fast = AudioHealth(concealed: 0, total: 1_000, inserted: 0, removed: 50, jitterBufferMs: 0)
+ let slow = AudioHealth(concealed: 0, total: 1_000, inserted: 50, removed: 0, jitterBufferMs: 0)
+ XCTAssertEqual(fast.driftRate, slow.driftRate)
+ XCTAssertTrue(fast.summary.contains("resampled"), fast.summary)
+ XCTAssertTrue(slow.summary.contains("resampled"), slow.summary)
+ }
+
+ func testItReadsTheStatsDictionaryTheEngineActuallyProduces() {
+ // The field names are libwebrtc's, spelled once in MediaEngine and
+ // once here. This is the test that fails if they stop agreeing.
+ let health = AudioHealth([
+ "concealedSamples": 7, "totalSamplesReceived": 70,
+ "insertedSamplesForDeceleration": 3, "removedSamplesForAcceleration": 4,
+ "jitterBufferDelayMs": 120,
+ ])
+ XCTAssertEqual(health.concealed, 7)
+ XCTAssertEqual(health.total, 70)
+ XCTAssertEqual(health.inserted, 3)
+ XCTAssertEqual(health.removed, 4)
+ XCTAssertEqual(health.jitterBufferMs, 120)
+ for field in MediaEngine.audioHealthFields {
+ XCTAssertTrue(
+ ["concealedSamples", "totalSamplesReceived", "insertedSamplesForDeceleration",
+ "removedSamplesForAcceleration"].contains(field),
+ "MediaEngine gathers \(field), which AudioHealth does not read")
+ }
+ }
+
+ func testAMissingFieldIsZeroAndNotACrash() {
+ // An older or newer libwebrtc that stops reporting one of these
+ // should cost the readout a number, not the call a panel.
+ let health = AudioHealth(["totalSamplesReceived": 1_000])
+ XCTAssertEqual(health.concealed, 0)
+ XCTAssertFalse(health.silent)
+ XCTAssertTrue(health.summary.contains("cleanly"), health.summary)
+ }
+}
+
+/// AudioRoutes on macOS is read-only, and everything it reports comes
+/// from CoreAudio rather than from WebRTC — which has no audio device API
+/// in its macOS slice at all. These run against the real machine, because
+/// the thing worth testing is that the CoreAudio calls are right.
+final class AudioRoutesTests: XCTestCase {
+ @MainActor func testMacReportsTheSystemDevicesAndOffersNoChoice() throws {
+ #if os(macOS)
+ let routes = AudioRoutes()
+ XCTAssertFalse(
+ routes.selectable,
+ "macOS cannot select an audio device for WebRTC; the UI must not offer a picker")
+ // A machine with no input at all is possible, so this asserts the
+ // shape rather than the presence: whatever is reported must be
+ // named, because an unnamed device tells a person nothing and the
+ // whole point of the Mac panel is saying what you are connected to.
+ for device in routes.inputs {
+ XCTAssertFalse(device.name.isEmpty, "a reported device must have a name")
+ XCTAssertFalse(device.selectable)
+ }
+ if !routes.outputName.isEmpty {
+ XCTAssertFalse(routes.outputName.hasPrefix("<"), "output name should be a real name")
+ }
+ // Reading twice must give the same answer and must not crash on the
+ // retained CFString CoreAudio hands back.
+ let again = AudioRoutes()
+ XCTAssertEqual(again.outputName, routes.outputName)
+ #else
+ throw XCTSkip("macOS only")
+ #endif
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/KnockWatchTests.swift b/native/apple/Tests/CallsKitTests/KnockWatchTests.swift
new file mode 100644
index 0000000..3ba339f
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/KnockWatchTests.swift
@@ -0,0 +1,84 @@
+import XCTest
+
+@testable import CallsKit
+
+/// The rules about when someone waiting is worth interrupting for. All of
+/// them live in KnockWatch precisely so they can be held here, without a
+/// notification centre or an app bundle.
+final class KnockWatchTests: XCTestCase {
+ private func peer(_ id: String, _ name: String) -> RosterPeer {
+ let json = #"{"peer":"\#(id)","name":"\#(name)","kind":"guest","gen":null}"#
+ return try! JSONDecoder().decode(RosterPeer.self, from: Data(json.utf8))
+ }
+
+ @MainActor func testSomeoneArrivingWhileAwayIsAnnouncedExactlyOnce() {
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ XCTAssertEqual(watch.arrivals(waiting: [bea], visible: false).map(\.name), ["Bea"])
+ // The same roster arriving again is the same person still waiting,
+ // not a second knock. State events repeat constantly.
+ XCTAssertTrue(watch.arrivals(waiting: [bea], visible: false).isEmpty)
+ XCTAssertTrue(watch.arrivals(waiting: [bea], visible: false).isEmpty)
+ }
+
+ @MainActor func testNobodyIsAnnouncedWhileTheQueueIsOnScreen() {
+ let watch = KnockWatch()
+ XCTAssertTrue(watch.arrivals(waiting: [peer("p1", "Bea")], visible: true).isEmpty)
+ }
+
+ @MainActor func testPeopleWhoArrivedWhileLookingAreNotAnnouncedOnTabbingAway() {
+ // The rule that makes the feature bearable. Someone watching the call
+ // sees the queue; tabbing away afterwards must not then produce a
+ // pile of notifications about people who have been there for a while.
+ // Only an arrival *during* the absence is news.
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ _ = watch.arrivals(waiting: [bea], visible: true)
+ XCTAssertTrue(
+ watch.arrivals(waiting: [bea], visible: false).isEmpty,
+ "someone seen while the app was in front must not be announced later")
+ let cass = peer("p2", "Cass")
+ XCTAssertEqual(
+ watch.arrivals(waiting: [bea, cass], visible: false).map(\.name), ["Cass"],
+ "the person who arrived while away is the only news")
+ }
+
+ @MainActor func testLeavingTheQueueAndKnockingAgainIsHeardAgain() {
+ // Someone let in, or who gave up and came back, is a new knock. A set
+ // that only ever grew would swallow the second one in silence.
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ XCTAssertEqual(watch.arrivals(waiting: [bea], visible: false).count, 1)
+ XCTAssertTrue(watch.arrivals(waiting: [], visible: false).isEmpty)
+ XCTAssertEqual(
+ watch.arrivals(waiting: [bea], visible: false).map(\.name), ["Bea"],
+ "a second knock from the same person must be heard")
+ }
+
+ @MainActor func testOnePersonSwappedForAnotherIsStillOneAnnouncement() {
+ // A count would read this as "still one waiting" and say nothing. The
+ // set is what makes the swap visible.
+ let watch = KnockWatch()
+ _ = watch.arrivals(waiting: [peer("p1", "Bea")], visible: false)
+ XCTAssertEqual(
+ watch.arrivals(waiting: [peer("p2", "Cass")], visible: false).map(\.name), ["Cass"])
+ }
+
+ @MainActor func testSeveralArrivingAtOnceAreAllAnnounced() {
+ let watch = KnockWatch()
+ let names = watch.arrivals(
+ waiting: [peer("p1", "Bea"), peer("p2", "Cass")], visible: false
+ ).map(\.name)
+ XCTAssertEqual(names, ["Bea", "Cass"])
+ }
+
+ @MainActor func testEndingACallForgetsItsQueue() {
+ let watch = KnockWatch()
+ let bea = peer("p1", "Bea")
+ _ = watch.arrivals(waiting: [bea], visible: false)
+ watch.reset()
+ XCTAssertEqual(
+ watch.arrivals(waiting: [bea], visible: false).map(\.name), ["Bea"],
+ "a queue from a finished meeting says nothing about the next one")
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/StartMeetingTests.swift b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift
new file mode 100644
index 0000000..f460fc9
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift
@@ -0,0 +1,67 @@
+import XCTest
+
+@testable import CallsKit
+
+/// 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.
+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.
+ model.signedIn = true
+ model.room = "abc123"
+ model.canStart = false
+ model.startMeeting()
+ XCTAssertFalse(model.starting)
+ XCTAssertFalse(model.error.isEmpty)
+ XCTAssertEqual(model.room, "abc123", "a refused start must not disturb the meeting in hand")
+ }
+
+ @MainActor func testStartingIsRefusedDuringACallRatherThanReplacingIt() {
+ let model = CallsModel()
+ model.signedIn = true
+ model.canStart = true
+ model.joining = true
+ model.error = ""
+ model.startMeeting()
+ XCTAssertFalse(model.starting)
+ // Silent, unlike the case above: this is a button that should not have
+ // been reachable, not a person being told something they can act on.
+ XCTAssertTrue(model.error.isEmpty)
+ }
+
+ @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()
+ model.starting = true
+ model.cancelSignIn()
+ XCTAssertFalse(model.starting)
+ model.starting = true
+ model.leave()
+ XCTAssertFalse(model.starting)
+ }
+
+ @MainActor func testAnInvitationClearsWhatTheLastSignInDecided() {
+ let model = CallsModel()
+ 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")
+ XCTAssertEqual(model.room, "")
+ XCTAssertFalse(model.signedIn)
+ }
+
+ @MainActor func testHostIsTheInstanceWithoutItsScheme() {
+ let model = CallsModel()
+ model.origin = "https://calls.oficina.works"
+ XCTAssertEqual(model.host, "calls.oficina.works")
+ model.origin = "http://127.0.0.1:8080"
+ XCTAssertEqual(model.host, "127.0.0.1")
+ }
+}
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index e89ccfa..0d30843 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -3,6 +3,7 @@
package bridge
import (
+ "context"
"encoding/base64"
"encoding/json"
"errors"
@@ -19,13 +20,13 @@ type Listener interface{ OnEvent(event string) }
type Client struct{ room *callscore.Room }
-// NewClient uses the ordinary session returned by the browser sign-in handoff.
-// This call does not mint, exchange, or accept an agent bearer token.
-// Cookies are scoped to the exact supplied origin and kept in memory only.
-func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, error) {
- if listener == nil {
- return nil, errors.New("a listener is required")
- }
+// 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.
+func sessionJar(origin, cookiesJSON string) (*cookiejar.Jar, error) {
u, err := url.Parse(origin)
if err != nil {
return nil, errors.New("invalid Calls origin")
@@ -56,6 +57,31 @@ func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, er
if !session {
return nil, errors.New("sign in to Calls first")
}
+ return jar, nil
+}
+
+// StartMeeting creates a meeting on an instance the app is signed in to
+// and answers its identifier, for the app that has no link because the
+// meeting does not exist yet. The caller joins it exactly as it joins a
+// meeting someone sent: NewClient with the same origin and cookies.
+func StartMeeting(origin, cookiesJSON string) (string, error) {
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err != nil {
+ return "", err
+ }
+ return callscore.StartMeeting(context.Background(), origin, &http.Client{Jar: jar})
+}
+
+// NewClient uses the ordinary session returned by the browser sign-in handoff.
+// This call does not mint, exchange, or accept an agent bearer token.
+func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, error) {
+ if listener == nil {
+ return nil, errors.New("a listener is required")
+ }
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err != nil {
+ return nil, err
+ }
r, err := callscore.New(origin, room, &http.Client{Jar: jar}, func(event callscore.Event) {
b, err := json.Marshal(event)
if err == nil {
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index cfc283b..d094e07 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -41,6 +41,7 @@ struct PhoneCall: View {
@ObservedObject var call: Call
let leave: () -> Void
@State private var showingChat = false
+ @State private var showingDevices = false
@State private var readChatCount = 0
@State private var grid = false
@State private var selectedPeer: String?
@@ -148,6 +149,9 @@ struct PhoneCall: View {
systemImage: call.cameraOff ? "video.slash.fill" : "video.fill"
) { call.toggleCamera() }
.buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff))
+ Button("Devices", systemImage: "slider.horizontal.3") { showingDevices = true }
+ .buttonStyle(CallActionStyle(color: .teal))
+ .accessibilityIdentifier("devices")
MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
Spacer(minLength: 0)
Button(chatButtonTitle, systemImage: "bubble.left.and.bubble.right.fill") {
@@ -164,6 +168,18 @@ struct PhoneCall: View {
.buttonStyle(CallActionStyle(color: .red, emphasized: true))
}.padding(16).background(.bar)
}
+ .task { await call.watchAudio() }
+ .sheet(isPresented: $showingDevices) {
+ NavigationStack {
+ ScrollView { DevicePanel(call: call).frame(maxWidth: .infinity, alignment: .leading) }
+ .navigationTitle("Devices").navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { showingDevices = false }
+ }
+ }
+ }.presentationDetents([.medium, .large])
+ }
.sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) {
NavigationStack {
ChatView(call: call, draft: draft).padding()
diff --git a/native/ios/Tests/AudioRouteTests.swift b/native/ios/Tests/AudioRouteTests.swift
new file mode 100644
index 0000000..6454991
--- /dev/null
+++ b/native/ios/Tests/AudioRouteTests.swift
@@ -0,0 +1,39 @@
+import AVFoundation
+import CallsKit
+import XCTest
+
+/// The Bluetooth rule, which only exists on iOS and so can only be tested
+/// here — CallsKitTests builds for macOS, where AVAudioSession does not
+/// exist at all.
+final class AudioRouteTests: XCTestCase {
+ func testBluetoothIsAllowedOnlyWhenItIsTheChosenMicrophone() {
+ // .allowBluetoothHFP is what lets a Bluetooth microphone be used, and
+ // it is also what drags the whole call to the 8 kHz hands-free
+ // profile the moment any headset connects. Left on unconditionally —
+ // as it was until this change — every pair of AirPods silently cost
+ // the call its audio quality in both directions, and nothing said so.
+ let ordinary = AudioRoutes.options(bluetoothInput: false)
+ XCTAssertFalse(
+ ordinary.contains(.allowBluetoothHFP),
+ "a call with no Bluetooth microphone chosen must not offer the hands-free profile")
+ XCTAssertTrue(
+ ordinary.contains(.allowBluetoothA2DP),
+ "output should still reach a headset, at A2DP quality")
+ XCTAssertTrue(ordinary.contains(.defaultToSpeaker))
+
+ // Picking the headset microphone is an informed trade, and then HFP
+ // is exactly what has to be allowed for it to work at all.
+ let headset = AudioRoutes.options(bluetoothInput: true)
+ XCTAssertTrue(headset.contains(.allowBluetoothHFP))
+ XCTAssertTrue(headset.contains(.defaultToSpeaker))
+ }
+
+ func testOnlyTheHandsFreePortCountsAsABluetoothMicrophone() {
+ // A2DP is output-only, so a Bluetooth device being used as a
+ // microphone is always on the hands-free port. Treating A2DP as a
+ // microphone would turn HFP back on for a headset nobody is
+ // recording from, which is the bug this rule exists to avoid.
+ XCTAssertEqual(AVAudioSession.Port.bluetoothHFP.rawValue, "BluetoothHFP")
+ XCTAssertNotEqual(AVAudioSession.Port.bluetoothA2DP, AVAudioSession.Port.bluetoothHFP)
+ }
+}
diff --git a/test/browser/audio.test.mjs b/test/browser/audio.test.mjs
index fe83d1e..70b96f9 100644
--- a/test/browser/audio.test.mjs
+++ b/test/browser/audio.test.mjs
@@ -21,8 +21,8 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url));
-const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url));
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
// lift returns the source of one `function name(...) {...}` by matching
// braces from its declaration — negotiation.test.mjs's helper, and it
@@ -63,10 +63,22 @@ test('the voice pipeline is on unless it is turned off', () => {
assert.equal(c.echoCancellation, true);
assert.equal(c.noiseSuppression, true);
assert.equal(c.autoGainControl, true);
- assert.equal(c.voiceIsolation, true);
});
-test('studio audio turns all of it off', () => {
+test('voice isolation is never asked for on top of the pipeline', () => {
+ // The second suppressor is the one that does the damage: Apple's
+ // system Voice Isolation running after the browser's own noise
+ // suppression returns a growl with the keystrokes still in it. The
+ // default path names three flags and leaves the fourth alone, so
+ // whatever the machine is set to is what a person gets.
+ assert.equal('voiceIsolation' in audioConstraints({}), false);
+ assert.equal('voiceIsolation' in audioConstraints({ rawAudio: false }), false);
+});
+
+test('studio audio turns all of it off, system voice isolation included', () => {
+ // The one place voiceIsolation is named: someone may have switched it
+ // on for the whole machine, and an interface in a treated room is
+ // exactly the case that has to be able to say no to it.
const c = audioConstraints({ rawAudio: true });
assert.equal(c.echoCancellation, false);
assert.equal(c.noiseSuppression, false);
@@ -79,10 +91,13 @@ test('the processing is asked for as an ideal and never as exact', () => {
// would mean a browser that cannot turn its filters off refuses
// getUserMedia outright — costing someone their microphone over a
// preference, which is the opposite of what the ladder in either file
- // is built to do. Booleans are ideals; objects are not.
+ // is built to do. Booleans are ideals; objects are not. A name the
+ // constraints leave out entirely is not under test here: that is the
+ // test above.
for (const prefs of [{}, { rawAudio: true }, { rawAudio: false }]) {
const c = audioConstraints(prefs);
for (const key of ['echoCancellation', 'noiseSuppression', 'autoGainControl', 'voiceIsolation']) {
+ if (!(key in c)) continue;
assert.equal(typeof c[key], 'boolean', key + ' must be a bare boolean, not ' + JSON.stringify(c[key]));
}
}
diff --git a/test/browser/chat.test.mjs b/test/browser/chat.test.mjs
index e0f5a0f..75d7973 100644
--- a/test/browser/chat.test.mjs
+++ b/test/browser/chat.test.mjs
@@ -33,7 +33,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const chatJS = fileURLToPath(new URL('../../internal/meet/static/chat.js', import.meta.url));
+const chatJS = fileURLToPath(new URL('../../internal/calls/static/chat.js', import.meta.url));
const src = readFileSync(chatJS, 'utf8');
// lift returns the source of one `function name(...) {...}` by matching
diff --git a/test/browser/devices.test.mjs b/test/browser/devices.test.mjs
index 6b6c4bc..dcf8873 100644
--- a/test/browser/devices.test.mjs
+++ b/test/browser/devices.test.mjs
@@ -20,8 +20,8 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url));
-const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url));
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
function lift(source, name, where) {
let start = source.indexOf('function ' + name + '(');
diff --git a/test/browser/native-auth.mjs b/test/browser/native-auth.mjs
index f1c648c..25d2624 100644
--- a/test/browser/native-auth.mjs
+++ b/test/browser/native-auth.mjs
@@ -11,7 +11,19 @@ try {
const page = await context.newPage();
await page.goto(config.url);
process.stdout.write("stage:signin\n");
- await page.locator('input[name="address"]').fill(config.address);
+ // A member's target lands on the sign-in page. An invitation now lands on
+ // the guest screen instead (guest.html, added with guest joining): join in
+ // the app, a name form to continue in the browser, and a link to sign in.
+ // Only the sign-in page carries the address field, so follow the link when
+ // that is what this target produced.
+ const address = page.locator('input[name="address"]');
+ if ((await address.count()) === 0) {
+ await Promise.all([
+ page.waitForURL('**/signin*'),
+ page.getByRole('link', { name: 'sign in', exact: true }).click(),
+ ]);
+ }
+ await address.fill(config.address);
await Promise.all([
page.waitForURL('**/signin?sent=1'),
page.locator('form[action="/signin"] button[type="submit"]').click(),
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index 28c2e5d..e104eec 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -33,6 +33,8 @@ try {
let invitationReady = false;
let invitationRefused = false;
let invitationSnapshotSaved = false;
+ let camera = null;
+ let cameraRefused = false;
let exited = false;
native.on('exit', () => { exited = true; });
// Native logs must never print credentials, SDP or candidates. Keep only
@@ -45,6 +47,7 @@ try {
if (value.invitationReady) invitationReady = true;
if (value.invitationRefused) invitationRefused = true;
if (value.invitationSnapshotSaved) invitationSnapshotSaved = true;
+ if (value.camera !== undefined) { camera = value.camera; cameraRefused = value.cameraRefused; }
if (value.stats) { sample = value; console.log(JSON.stringify(value)); }
} catch {}
});
@@ -90,6 +93,38 @@ try {
});
await wait(async () => await browserAudio() > 0, 'browser receives native audio');
console.log('PASS browser decoded native video and received native audio');
+ // Changing camera mid-call. The switch rests on the capturer feeding the
+ // same RTCVideoSource, so the RTCVideoTrack every connection is already
+ // sending does not change and nothing renegotiates — the native
+ // counterpart of the browser's replaceTrack. What proves it is the far
+ // side: frames must keep arriving across the switch, on the same
+ // connection, with no new m-line.
+ {
+ const beforeFrames = await remoteFrames();
+ const beforeLines = await page.evaluate(() => window.meet.state().peers.length);
+ native.stdin.write('camera synthetic-480\n');
+ await wait(() => camera === 'synthetic-480', 'native reports the camera it switched to');
+ assert.equal(cameraRefused, false, 'camera switch was refused');
+ await wait(async () => await remoteFrames() > beforeFrames + 15, 'browser keeps decoding across a camera switch');
+ assert.equal(
+ await page.evaluate(() => window.meet.state().peers.length), beforeLines,
+ 'a camera switch must not change the roster or rebuild the pair');
+ // Two switches at once are two capture restarts on one device. Queued,
+ // the last one wins; run at once, the survivor is whichever session
+ // opened last, which is not the one a person chose.
+ native.stdin.write('camera synthetic-320\n');
+ native.stdin.write('camera synthetic-480\n');
+ await wait(() => camera === 'synthetic-480', 'the last of two racing switches is the one that holds');
+ const racedFrames = await remoteFrames();
+ await wait(async () => await remoteFrames() > racedFrames + 15, 'media survives racing camera switches');
+ // And a camera that is not there is refused without costing the call
+ // its picture.
+ native.stdin.write('camera no-such-camera\n');
+ await wait(() => cameraRefused === true, 'an unknown camera is refused');
+ const refusedFrames = await remoteFrames();
+ await wait(async () => await remoteFrames() > refusedFrames + 15, 'media survives a refused camera switch');
+ console.log('PASS camera switched mid-call without disturbing the connection');
+ }
await page.locator('[data-chat-text]').fill('browser chat probe');
await page.locator('[data-chat-send]').click();
await wait(() => sample?.receivedChatProbe === true, 'native displays browser chat');
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index 5097f5f..79a7aec 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -25,7 +25,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
// lift returns the source of one top-level-ish `function name(...) {...}`
// by matching braces from its declaration.
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs
index 68dd7fe..345b452 100644
--- a/test/browser/room-lane.test.mjs
+++ b/test/browser/room-lane.test.mjs
@@ -21,7 +21,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
const src = readFileSync(roomJS, 'utf8');
function lift(source, name) {
diff --git a/test/browser/room-share.test.mjs b/test/browser/room-share.test.mjs
index 9b257db..aa20f7a 100644
--- a/test/browser/room-share.test.mjs
+++ b/test/browser/room-share.test.mjs
@@ -30,7 +30,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
const src = readFileSync(roomJS, 'utf8');
// lift returns the source of one `function name(...) {...}` by matching