Merge branch 'feat/native-start-meeting' into main
39 files changed,
+1961
−62
callscore/auth.go+23 −9callscore/auth_test.go+54 −0callscore/meetings.go+75 −0callscore/meetings_test.go+113 −0callscore/transport.go+41 −9internal/calls/app.go+1 −0internal/calls/native_auth.go+53 −0internal/calls/native_auth_test.go+131 −0internal/calls/static/devices.js+13 −3internal/calls/static/room.js+13 −3internal/callstest/browser_test.go+37 −0internal/callstest/native_browser_test.go+4 −0native/README.md+72 −5native/apple/Sources/CallsInterop/Interop.swift+8 −0native/apple/Sources/CallsInterop/SyntheticVideo.swift+27 −1native/apple/Sources/CallsKit/Audio.swift+168 −0native/apple/Sources/CallsKit/AudioHealth.swift+79 −0native/apple/Sources/CallsKit/Call.swift+91 −0native/apple/Sources/CallsKit/CallsLobby.swift+47 −9native/apple/Sources/CallsKit/CallsModel.swift+64 −0native/apple/Sources/CallsKit/Capture.swift+88 −3native/apple/Sources/CallsKit/DevicePanel.swift+154 −0native/apple/Sources/CallsKit/Knocks.swift+101 −0native/apple/Sources/CallsKit/MacCallView.swift+19 −0native/apple/Sources/CallsKit/Media.swift+49 −1native/apple/Tests/CallsKitTests/AudioHealthTests.swift+123 −0native/apple/Tests/CallsKitTests/KnockWatchTests.swift+84 −0native/apple/Tests/CallsKitTests/StartMeetingTests.swift+67 −0native/bridge/bridge.go+33 −7native/ios/Sources/CallsPhone.swift+16 −0native/ios/Tests/AudioRouteTests.swift+39 −0test/browser/audio.test.mjs+20 −5test/browser/chat.test.mjs+1 −1test/browser/devices.test.mjs+2 −2test/browser/native-auth.mjs+13 −1test/browser/native.mjs+35 −0test/browser/negotiation.test.mjs+1 −1test/browser/room-lane.test.mjs+1 −1test/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 |