Merge fix/title-and-app-link: no title pill; Join in the app as the person signed in (spec 15.25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
55 files changed,
+2225
−470
Makefile+1 −1README.md+10 −2callscore/auth.go+38 −2callscore/meetings.go+19 −1callscore/session.go+47 −8callscore/session_test.go+48 −8callscore/transport.go+6 −1docs/superpowers/specs/2026-09-07-meet-design.md+43 −0internal/calls/app.go+2 −0internal/calls/guest.go+1 −1internal/calls/handlers_join.go+6 −1internal/calls/handlers_meetings.go+25 −9internal/calls/handlers_meetings_test.go+77 −14internal/calls/handlers_room.go+4 −2internal/calls/migrations/0006_alter_meetings.sql+4 −0internal/calls/migrations/schema.sql+1 −1internal/calls/models.go+13 −2internal/calls/native_auth.go+171 −7internal/calls/native_auth_test.go+135 −0internal/calls/static/app-offer.js+45 −0internal/calls/static/call-window.js+17 −48internal/calls/static/calls.css+56 −66internal/calls/static/noscript.css+31 −0internal/calls/static/room.js+22 −23internal/calls/store.go+12 −1internal/calls/templates/join.html+1 −1internal/calls/templates/native.html+16 −0internal/calls/templates/room.html+40 −39internal/callstest/browser_test.go+1 −0internal/callstest/chrome_test.go+11 −4internal/callstest/native_applink_browser_test.go+276 −0internal/callstest/room_test.go+90 −59native/README.md+8 −2native/apple/Sources/CallsInterop/CallSnapshots.swift+9 −2native/apple/Sources/CallsKit/Call.swift+6 −1native/apple/Sources/CallsKit/CallChrome.swift+44 −3native/apple/Sources/CallsKit/CallFixtures.swift+6 −0native/apple/Sources/CallsKit/CallsLobby.swift+26 −8native/apple/Sources/CallsKit/CallsModel.swift+129 −36native/apple/Sources/CallsKit/MacCallView.swift+0 −3native/apple/Sources/CallsKit/MacFullWindowCall.swift+75 −47native/apple/Sources/CallsKit/SessionStore.swift+43 −0native/apple/Sources/CallsKit/Wire.swift+3 −0native/apple/Tests/CallsKitTests/AppLinkTests.swift+191 −0native/apple/Tests/CallsKitTests/CallChromeTests.swift+65 −0native/architecture.md+11 −0native/bridge/auth.go+12 −0native/bridge/bridge.go+11 −5native/bridge/bridge_test.go+4 −0native/ios/Sources/PhoneFullScreenCall.swift+23 −17native/ios/UITests/CallsPhoneUITests.swift+25 −1native/ios/project.yml+1 −1test/browser/call.mjs+142 −34test/browser/native-applink.mjs+104 −0test/browser/room-window.test.mjs+18 −9
CI passed — run details
diff --git a/Makefile b/Makefile| index 4b39b17..d44b73b 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -178,7 +178,7 @@ native-browser: native-mac |
| # The gate runs CallsInterop, which native-mac does not build; without |
| # this it runs whatever binary a previous build left behind. |
| swift build --package-path native/apple --product CallsInterop |
| - CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 25m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|MacScreenRecovery|BrowserAuthentication|MacAudioSelfTest|MacAudioStartStall)' -v ./internal/callstest/ |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 25m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|MacScreenRecovery|BrowserAuthentication|BrowserAppLink|MacAudioSelfTest|MacAudioStartStall)' -v ./internal/callstest/ |
| # Two apps in one call, sharing screens to each other. Separate from |
| # native-browser because two unsigned apps on one Mac do not reliably |
diff --git a/README.md b/README.md| index 8982b36..3863229 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -202,6 +202,14 @@ admits queued guests up to the call capacity. |
| Invitation and browser holding pages offer **Join in the app**, using the |
| `oficina-calls://join?url=…` URL scheme registered by the Mac and iOS bundles. |
| -The app opens the meeting link in its lobby for review, then uses the existing |
| -browser authorization flow (including anonymous guest sessions). Browser joining |
| +On the invitation page (nobody signed in yet) the link carries the invitation, |
| +and the app runs its own browser sign-in for it, guests included. On the |
| +holding page (signed in, as a member or a guest) it goes through |
| +`/m/{sqid}/app` first, which makes a five-minute, one-use *offer* bound to this |
| +browser session and opens the app with it (`&offer=…`); the app's ordinary |
| +native sign-in request names the offer, and the page the person is on turns |
| +into the confirmation for it, with the app's code — so the app gets this |
| +person's own standing in this meeting without a second sign-in, in whatever |
| +browser they happen to be using. An app already signed in as someone who can |
| +join the meeting uses its own session and needs no browser. Browser joining |
| remains available when the app is not installed. |
diff --git a/callscore/auth.go b/callscore/auth.go| index 550fd58..0d3f89d 100644 |
| --- a/callscore/auth.go |
| +++ b/callscore/auth.go |
| @@ -78,6 +78,27 @@ func invitationRoom(response *http.Response) (string, error) { |
| } |
| func BeginAuthentication(ctx context.Context, address string) (*Authentication, error) { |
| + return beginAuthentication(ctx, address, "") |
| +} |
| + |
| +// ErrOfferExpired is BeginOfferAuthentication's refusal of an offer that |
| +// has gone: five minutes passed, or the offer was already taken up. |
| +var ErrOfferExpired = errors.New("this Join in the app link has expired or was already used; choose Join in the app on the meeting page again") |
| + |
| +// BeginOfferAuthentication is BeginAuthentication for the web's "Join in |
| +// the app" (2026-10-02): the link that opened the app carries an offer |
| +// the person's signed-in browser made, and the request names it, so the |
| +// browser page that made it — not whichever browser BrowserURL would |
| +// open — asks the person to confirm. Everything else is the same: the |
| +// verifier stays here, the session is released only to it, once. |
| +func BeginOfferAuthentication(ctx context.Context, address, offer string) (*Authentication, error) { |
| + if !authToken(offer) { |
| + return nil, errors.New("this Join in the app link is not valid") |
| + } |
| + return beginAuthentication(ctx, address, offer) |
| +} |
| + |
| +func beginAuthentication(ctx context.Context, address, offer string) (*Authentication, error) { |
| origin, target, err := meetingTarget(address) |
| if err != nil { |
| return nil, err |
| @@ -94,23 +115,38 @@ func BeginAuthentication(ctx context.Context, address string) (*Authentication, |
| } |
| 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}) |
| + input := map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(hash[:]), "target": a.target} |
| + if offer != "" { |
| + input["offer"] = offer |
| + } |
| + res, err := a.post("request", input) |
| if err != nil { |
| cancel() |
| return nil, err |
| } |
| defer res.Body.Close() |
| + if offer != "" && res.StatusCode == http.StatusGone { |
| + cancel() |
| + return nil, ErrOfferExpired |
| + } |
| if res.StatusCode != 200 { |
| cancel() |
| return nil, errors.New("this Calls server does not support native sign-in yet, or is temporarily unavailable") |
| } |
| var out struct { |
| - ID string `json:"id"` |
| + ID string `json:"id"` |
| + Offer bool `json:"offer"` |
| } |
| if json.NewDecoder(io.LimitReader(res.Body, 2048)).Decode(&out) != nil || !authToken(out.ID) { |
| cancel() |
| return nil, errors.New("invalid sign-in response") |
| } |
| + // A server that ignored the offer made an ordinary request, which |
| + // only BrowserURL's browser can approve. Said rather than waited on. |
| + if offer != "" && !out.Offer { |
| + cancel() |
| + return nil, errors.New("this Calls server cannot hand a web sign-in to the app; choose Sign in instead") |
| + } |
| a.id = out.ID |
| return a, nil |
| } |
diff --git a/callscore/meetings.go b/callscore/meetings.go| index bfc537c..c7e7033 100644 |
| --- a/callscore/meetings.go |
| +++ b/callscore/meetings.go |
| @@ -25,6 +25,13 @@ import ( |
| // 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) { |
| + return StartMeetingIn(ctx, origin, "", client) |
| +} |
| + |
| +// StartMeetingIn is StartMeeting naming the app's IANA time zone, so the |
| +// title the server writes ("Call, 2 Oct 15:40") is the starter's own time |
| +// rather than the instance's. An empty or unknown zone is the instance's. |
| +func StartMeetingIn(ctx context.Context, origin, zone string, client *http.Client) (string, error) { |
| trimmed, err := checkOrigin(origin) |
| if err != nil { |
| return "", err |
| @@ -35,7 +42,7 @@ func StartMeeting(ctx context.Context, origin string, client *http.Client) (stri |
| } |
| ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| defer cancel() |
| - req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/meetings/start", bytes.NewReader([]byte("{}"))) |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/meetings/start", bytes.NewReader(startBody(zone))) |
| if err != nil { |
| return "", errors.New("cannot build Calls request") |
| } |
| @@ -122,3 +129,14 @@ func RegisterDevice(ctx context.Context, origin string, client *http.Client, tok |
| return errors.New("Calls refused this device registration") |
| } |
| } |
| + |
| +// startBody is the start request: an empty object, or the zone when the |
| +// app knows it. A zone too long to be an IANA name is left out rather |
| +// than sent for the server to refuse. |
| +func startBody(zone string) []byte { |
| + if zone == "" || len(zone) > 64 { |
| + return []byte("{}") |
| + } |
| + b, _ := json.Marshal(map[string]string{"zone": zone}) |
| + return b |
| +} |
diff --git a/callscore/session.go b/callscore/session.go| index ecf18b7..3bec96c 100644 |
| --- a/callscore/session.go |
| +++ b/callscore/session.go |
| @@ -13,6 +13,14 @@ import ( |
| // is forgotten and the person signs in again. |
| var ErrSignedOut = errors.New("sign in to Calls again") |
| +// ErrNoAccess is OpenLink's answer for a /m/ address the held session |
| +// cannot enter: it is signed in, as somebody who is not a member and has |
| +// no invitation to that meeting. The app's sign-in is somebody else's — |
| +// a guest signed in on the web, say, while the app is signed in as a |
| +// member of another team on the same server — and the meeting needs |
| +// this person's own sign-in, or their invitation (2026-10-02). |
| +var ErrNoAccess = errors.New("this sign-in cannot join that meeting") |
| + |
| // Standing is what a held session is on its instance, as far as the app |
| // needs to know before it decides whether to keep it. |
| type Standing string |
| @@ -81,8 +89,11 @@ func CheckSession(ctx context.Context, origin string, client *http.Client) (Stan |
| // sign-in would only mint another one like it. |
| // |
| // An address with no path answers no meeting, which is the instance |
| -// itself. A /m/ address answers its identifier without asking: joining |
| -// is where access to it is decided. A /j/ address is followed exactly |
| +// itself. A /m/ address is asked about first, with one GET of its info |
| +// (the same request the room makes): until 2026-10-02 it answered its |
| +// identifier without asking, and a session without standing found out |
| +// only when the join failed, with nothing to offer instead. A session |
| +// that cannot enter it is ErrNoAccess. A /j/ address is followed exactly |
| // as the browser would follow it with this session — which is what |
| // consumes the invitation and, for someone who is not a member, grants |
| // the meeting — and the room is read off the redirect. |
| @@ -104,18 +115,18 @@ func OpenLink(ctx context.Context, origin, address string, client *http.Client) |
| if target == "/" { |
| return "", nil |
| } |
| - if room, ok := strings.CutPrefix(target, "/m/"); ok { |
| - if !validRoom(room) { |
| - return "", errors.New("invalid meeting identifier") |
| - } |
| - return room, nil |
| - } |
| safe, err := safeClient(client) |
| if err != nil { |
| return "", err |
| } |
| ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| defer cancel() |
| + if room, ok := strings.CutPrefix(target, "/m/"); ok { |
| + if !validRoom(room) { |
| + return "", errors.New("invalid meeting identifier") |
| + } |
| + return room, meetingAccess(ctx, safe, trimmed+target+"/info") |
| + } |
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+target, nil) |
| if err != nil { |
| return "", errors.New("cannot build Calls request") |
| @@ -179,3 +190,31 @@ func SignOut(ctx context.Context, origin string, client *http.Client) error { |
| } |
| return nil |
| } |
| + |
| +// meetingAccess reads whether the held session may enter a meeting, by |
| +// its info: 200 is yes; the sign-in redirect or 401 is a session that |
| +// has gone; 404 is a session that is fine but not for this meeting — |
| +// or a meeting that has ended, which the app can do nothing about |
| +// either way and says the same of. |
| +func meetingAccess(ctx context.Context, client *http.Client, info string) error { |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, info, nil) |
| + if err != nil { |
| + return errors.New("cannot build Calls request") |
| + } |
| + req.Header.Set("Accept", "application/json") |
| + res, err := client.Do(req) |
| + if err != nil { |
| + return errors.New("cannot reach Calls") |
| + } |
| + res.Body.Close() |
| + switch { |
| + case res.StatusCode == http.StatusOK: |
| + return nil |
| + case res.StatusCode == http.StatusUnauthorized, res.StatusCode >= 300 && res.StatusCode < 400: |
| + return ErrSignedOut |
| + case res.StatusCode == http.StatusNotFound, res.StatusCode == http.StatusForbidden: |
| + return ErrNoAccess |
| + default: |
| + return errors.New("cannot open that meeting just now") |
| + } |
| +} |
diff --git a/callscore/session_test.go b/callscore/session_test.go| index 80b9450..c2b0a19 100644 |
| --- a/callscore/session_test.go |
| +++ b/callscore/session_test.go |
| @@ -79,18 +79,13 @@ func TestCheckSessionNeverFollowsTheRedirect(t *testing.T) { |
| } |
| } |
| -func TestOpenLinkAnswersAMeetingWithoutAskingAndTheInstanceAsNone(t *testing.T) { |
| +func TestOpenLinkAnswersTheInstanceAsNoneWithoutAsking(t *testing.T) { |
| var calls atomic.Int32 |
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| defer server.Close() |
| - for address, want := range map[string]string{ |
| - server.URL + "/m/abc123": "abc123", |
| - server.URL + "/m/abc123/join": "abc123", |
| - server.URL: "", |
| - server.URL + "/": "", |
| - } { |
| + for _, address := range []string{server.URL, server.URL + "/"} { |
| got, err := OpenLink(t.Context(), server.URL, address, server.Client()) |
| - if err != nil || got != want { |
| + if err != nil || got != "" { |
| t.Errorf("%q gave %q, %v", address, got, err) |
| } |
| } |
| @@ -99,6 +94,51 @@ func TestOpenLinkAnswersAMeetingWithoutAskingAndTheInstanceAsNone(t *testing.T) |
| } |
| } |
| +// A /m/ address is asked about (2026-10-02): its info, with the session, |
| +// once, redirects not followed. Until then it was answered without |
| +// asking, and an app signed in as somebody without standing — a member's |
| +// app opened from a guest's browser — found out only when the join |
| +// failed. |
| +func TestOpenLinkAsksWhetherTheSessionMayEnterAMeeting(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + handler http.HandlerFunc |
| + want string |
| + signedOut bool |
| + noAccess bool |
| + }{ |
| + {"may enter", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"title":"x"}`)) }, "abc123", false, false}, |
| + {"session gone", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, "", true, false}, |
| + {"not this person's meeting", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }, "", false, true}, |
| + {"server trouble", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(502) }, "", false, false}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + var asked []string |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + asked = append(asked, r.Method+" "+r.URL.Path) |
| + c.handler(w, r) |
| + })) |
| + defer server.Close() |
| + for _, address := range []string{server.URL + "/m/abc123", server.URL + "/m/abc123/join"} { |
| + asked = nil |
| + got, err := OpenLink(t.Context(), server.URL, address, server.Client()) |
| + if len(asked) != 1 || asked[0] != "GET /m/abc123/info" { |
| + t.Fatalf("%s asked %v", address, asked) |
| + } |
| + if c.want != "" && (err != nil || got != c.want) { |
| + t.Fatalf("%s gave %q, %v", address, got, err) |
| + } |
| + if c.want == "" && err == nil { |
| + t.Fatalf("%s opened", address) |
| + } |
| + if errors.Is(err, ErrSignedOut) != c.signedOut || errors.Is(err, ErrNoAccess) != c.noAccess { |
| + t.Fatalf("%s: %v", address, err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestOpenLinkFollowsAnInvitationWithTheSessionAndReadsTheRoom(t *testing.T) { |
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/j/token_1" { |
diff --git a/callscore/transport.go b/callscore/transport.go| index 082de13..a953645 100644 |
| --- a/callscore/transport.go |
| +++ b/callscore/transport.go |
| @@ -224,7 +224,12 @@ func (t *transport) ice(ctx context.Context, peer protocol.PeerID) (ICEServers, |
| // Info is the same metadata the server renders on the holding screen. |
| type Info struct { |
| - Title string `json:"title"` |
| + Title string `json:"title"` |
| + // Named is a title someone chose rather than one Calls wrote ("Call, |
| + // 2 Oct 15:40"): the apps show it over the call. A server from before |
| + // 0.4.7 does not say, so its titles are left off — the default for |
| + // every call now. |
| + Named bool `json:"named"` |
| Name string `json:"name"` |
| Standing string `json:"standing"` |
| Floor bool `json:"floor"` |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 97222f9..fd2dc66 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -2665,3 +2665,46 @@ in either order. |
| **Not logged, not stored.** As §15.19. The apps log "… can hear us" in the |
| connection log (`os_log` category `connection`), the browser on its |
| console, each with the first six characters of the peer id only. |
| + |
| +### 15.25 §3.4, §8.4 — no title pill, and Join in the app as the person signed in (2026-10-02) |
| + |
| +**The title.** The full-window call on Mac, iPhone and web carried a pill by |
| +the traffic lights: the title and a few words of state ("Call, 2 Oct 15:40 |
| +Europe/Dublin / In the meeting"). Paul approved removing it. The state is the |
| +notices' to say, and each one the pill carried — Connecting, Waiting to be let |
| +in, The room is full, Reconnecting, the call over — has a notice (the apps' |
| +`CallNotices`, which now also turn any status other than "In the meeting" into |
| +one; the web's `statusLine`). A title someone chose is shown as a window title: |
| +plain, centred in the top strip over the existing dark scrim, fading with the |
| +controls (Mac: on the traffic lights' line; iPhone: small, inside the safe |
| +area). A title Calls wrote is not shown. Which is which is a flag set where the |
| +title is made — `meetings.auto_title`, by Start a call and the apps' Start |
| +meeting — never read back out of the words; `/m/{sqid}/info` says `named`. The |
| +window's own title (Window menu, Mission Control, VoiceOver) and the page's |
| +`<title>` are the meeting's either way. |
| + |
| +**The zone.** From `f86d4da` (2026-10-01) an automatic title in the instance |
| +zone ended with the zone's name, so that a reader elsewhere was not shown a |
| +bare time in a zone they could not see; every meeting the apps started had it, |
| +because they sent no zone. The apps now send theirs, the title is "Call, 2 Oct |
| +15:40", and the zone is kept beside it as data (`meetings.title_zone`). |
| + |
| +**The web menu.** The pill's ☰ menu — the standing line, Meetings, Settings, |
| +Help, Sign out — is the bar's ⋯ More menu, for guests too, with End meeting |
| +for everyone last for a member. With no script the header is a plain block |
| +with the title, the standing, the floor notice and the same navigation. |
| + |
| +**Join in the app.** Reported by a Calls user: "App link doesn't work if |
| +you're signed in". The holding screen's link was |
| +`oficina-calls://join?url=<origin>/m/<sqid>`, which names a meeting and not a |
| +person, and the app's session is its own: an app signed in as someone without |
| +standing joined and failed (or joined as the wrong person), and an app with |
| +none signed in in the default browser — where a guest, who has no account, |
| +reached a sign-in page that sends a non-member no mail. Now the link goes |
| +through `/m/{sqid}/app`, which makes an offer bound to the browser's session |
| +(native/architecture.md); the app's handoff request names it, and the browser |
| +the person is in confirms it. Same verifier exchange, one use, five minutes, |
| +the browser session's own standing, a guest still limited to their meeting. |
| +`OpenLink` asks `/info` before answering a `/m/` address, so an app session |
| +without standing is told so (`noAccess`) instead of failing at the join. |
| + |
diff --git a/internal/calls/app.go b/internal/calls/app.go| index 816ce16..a359d81 100644 |
| --- a/internal/calls/app.go |
| +++ b/internal/calls/app.go |
| @@ -501,6 +501,8 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/continue", a.continueSignedIn) |
| r.Get("/welcome", a.welcome) |
| r.Get("/m/{sqid}/join", a.holding) |
| + r.Get("/m/{sqid}/app", a.appOffer) |
| + r.Get("/native/offer", a.offerPage) |
| r.Get("/m/{sqid}", a.room) |
| r.Get("/m/{sqid}/info", a.roomInfo) |
| r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove) |
diff --git a/internal/calls/guest.go b/internal/calls/guest.go| index cdd4f6b..9617b5a 100644 |
| --- a/internal/calls/guest.go |
| +++ b/internal/calls/guest.go |
| @@ -16,7 +16,7 @@ func (a *app) requireParticipant(next http.Handler) http.Handler { |
| return a.requireSignedInPage(a.requirePerson(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| p, _ := currentPerson(r) |
| path := r.URL.Path |
| - if p.Anonymous && !strings.HasPrefix(path, "/m/") && path != "/native/authorize" && path != "/native/complete" { |
| + if p.Anonymous && !strings.HasPrefix(path, "/m/") && path != "/native/authorize" && path != "/native/complete" && path != "/native/offer" { |
| a.notFound(w, r) |
| return |
| } |
diff --git a/internal/calls/handlers_join.go b/internal/calls/handlers_join.go| index 45bce80..b84390f 100644 |
| --- a/internal/calls/handlers_join.go |
| +++ b/internal/calls/handlers_join.go |
| @@ -89,7 +89,12 @@ func (a *app) holding(w http.ResponseWriter, r *http.Request) { |
| "Sqid": m.Sqid, "Title": title, "Starts": a.fmtStart(m.StartsAt), |
| "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| "Floor": !a.relayConfigured(), "OpenElsewhere": openElsewhere, |
| - "NativeURL": nativeJoinURL(a.cfg.Origin + "/m/" + m.Sqid), |
| + // Not oficina-calls://join?url=…/m/{sqid} (until 2026-10-02): the |
| + // app has a session of its own, or none, and the meeting's |
| + // address told it nothing about who this is, so a guest — and |
| + // anyone whose default browser was not this one — could not get |
| + // in. The offer hands this browser's person over (appOffer). |
| + "AppHref": "/m/" + m.Sqid + "/app", |
| })) |
| } |
diff --git a/internal/calls/handlers_meetings.go b/internal/calls/handlers_meetings.go| index d9d7e53..e1604d7 100644 |
| --- a/internal/calls/handlers_meetings.go |
| +++ b/internal/calls/handlers_meetings.go |
| @@ -124,17 +124,33 @@ func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) { |
| a.renderLinkOnce(w, r, m, token, false) |
| } |
| +// autoTitle is a title Calls writes for a meeting nobody named, and the |
| +// zone its time is in. |
| +type autoTitle struct { |
| + Text string |
| + Zone string |
| +} |
| + |
| // callTitle is the title a one-click "Start a call" meeting gets: |
| // "Call, 10 Sep 09:15", in the starter's own time zone when the browser |
| -// said which (zone.js), so it reads as the time they started it. Without |
| -// one it is the instance zone, and says so ("Call, 10 Sep 09:15 UTC"): |
| -// a bare time in a zone the reader cannot see is a wrong time for most |
| -// of the people reading it. now is passed in so a test can fix it. |
| -func callTitle(now time.Time, local *time.Location, instance *time.Location, instanceName string) string { |
| +// or the app said which (zone.js, the apps' Start meeting), so it reads |
| +// as the time they started it, and otherwise in the instance's. now is |
| +// passed in so a test can fix it. |
| +// |
| +// The zone is not in the words. From f86d4da (2026-10-01, the shared |
| +// chrome) until 2026-10-02 a title in the instance zone ended with its |
| +// name — "Call, 2 Oct 15:40 Europe/Dublin" — so that a reader elsewhere |
| +// was not shown a bare time in a zone they could not see. That was the |
| +// title every meeting started from the Mac and iPhone apps got, because |
| +// they sent no zone, and Paul's screenshot of the Mac call is what it |
| +// looked like. The apps now send theirs, the name goes to TitleZone, and |
| +// the title reads as a title. |
| +func callTitle(now time.Time, local *time.Location, instance *time.Location) autoTitle { |
| + loc := instance |
| if local != nil { |
| - return "Call, " + now.In(local).Format("2 Jan 15:04") |
| + loc = local |
| } |
| - return "Call, " + now.In(instance).Format("2 Jan 15:04") + " " + instanceName |
| + return autoTitle{Text: "Call, " + now.In(loc).Format("2 Jan 15:04"), Zone: loc.String()} |
| } |
| // callZone is the browser's IANA zone from the Start a call form, or nil |
| @@ -156,8 +172,8 @@ func callZone(name string) *time.Location { |
| // and answers with the same link-once page. |
| func (a *app) startCall(w http.ResponseWriter, r *http.Request) { |
| p, _ := currentPerson(r) |
| - title := callTitle(a.store.now(), callZone(r.PostFormValue("zone")), a.cfg.zone, a.cfg.Zone) |
| - m, token, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p)) |
| + title := callTitle(a.store.now(), callZone(r.PostFormValue("zone")), a.cfg.zone) |
| + m, token, err := a.store.createCall(r.Context(), title, actorFor(p)) |
| if err != nil { |
| a.logger.Error("calls: start call", "err", err) |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
diff --git a/internal/calls/handlers_meetings_test.go b/internal/calls/handlers_meetings_test.go| index 52cb714..ec558bd 100644 |
| --- a/internal/calls/handlers_meetings_test.go |
| +++ b/internal/calls/handlers_meetings_test.go |
| @@ -1,6 +1,7 @@ |
| package calls |
| import ( |
| + "strings" |
| "testing" |
| "time" |
| ) |
| @@ -19,16 +20,20 @@ func TestCallTitle(t *testing.T) { |
| t.Fatal(err) |
| } |
| cases := []struct { |
| - name string |
| - now time.Time |
| - zone *time.Location |
| - want string |
| + name string |
| + now time.Time |
| + zone *time.Location |
| + want string |
| + wantZone string |
| }{ |
| { |
| - name: "no browser zone: the instance zone, named", |
| - now: time.Date(2026, 9, 30, 20, 47, 0, 0, time.UTC), |
| - zone: nil, |
| - want: "Call, 30 Sep 20:47 UTC", |
| + // Not "Call, 30 Sep 20:47 UTC" (f86d4da until 2026-10-02): |
| + // the zone is data, not words. |
| + name: "no browser zone: the instance zone, kept but not in the title", |
| + now: time.Date(2026, 9, 30, 20, 47, 0, 0, time.UTC), |
| + zone: nil, |
| + want: "Call, 30 Sep 20:47", |
| + wantZone: "UTC", |
| }, |
| { |
| name: "UTC", |
| @@ -43,10 +48,11 @@ func TestCallTitle(t *testing.T) { |
| want: "Call, 3 Sep 09:05", |
| }, |
| { |
| - name: "a zone ahead of UTC, same day", |
| - now: time.Date(2026, 9, 10, 9, 15, 0, 0, time.UTC), |
| - zone: dublin, // Irish summer time is UTC+1 in September |
| - want: "Call, 10 Sep 10:15", |
| + name: "a zone ahead of UTC, same day", |
| + now: time.Date(2026, 9, 10, 9, 15, 0, 0, time.UTC), |
| + zone: dublin, // Irish summer time is UTC+1 in September |
| + want: "Call, 10 Sep 10:15", |
| + wantZone: "Europe/Dublin", |
| }, |
| { |
| name: "a zone far enough ahead to roll the date over", |
| @@ -57,8 +63,12 @@ func TestCallTitle(t *testing.T) { |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
| - if got := callTitle(c.now, c.zone, time.UTC, "UTC"); got != c.want { |
| - t.Errorf("callTitle(%s, %s) = %q, want %q", c.now, c.zone, got, c.want) |
| + got := callTitle(c.now, c.zone, time.UTC) |
| + if got.Text != c.want { |
| + t.Errorf("callTitle(%s, %s) = %q, want %q", c.now, c.zone, got.Text, c.want) |
| + } |
| + if c.wantZone != "" && got.Zone != c.wantZone { |
| + t.Errorf("callTitle(%s, %s) zone = %q, want %q", c.now, c.zone, got.Zone, c.wantZone) |
| } |
| }) |
| } |
| @@ -76,3 +86,56 @@ func TestCallZone(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestCallTitleNeverNamesItsZone: whatever zone the title is written in, |
| +// the title is the day and time and nothing after them — the reported |
| +// "Call, 2 Oct 15:40 Europe/Dublin" — and the zone is returned beside it. |
| +func TestCallTitleNeverNamesItsZone(t *testing.T) { |
| + dublin, _ := time.LoadLocation("Europe/Dublin") |
| + now := time.Date(2026, 10, 2, 14, 40, 0, 0, time.UTC) |
| + for _, local := range []*time.Location{nil, dublin, time.UTC} { |
| + got := callTitle(now, local, dublin) |
| + if got.Text != "Call, 2 Oct 15:40" && local != time.UTC { |
| + t.Errorf("callTitle in %v = %q", local, got.Text) |
| + } |
| + if strings.Contains(got.Text, "/") || strings.HasSuffix(got.Text, "UTC") { |
| + t.Errorf("callTitle in %v names its zone: %q", local, got.Text) |
| + } |
| + if got.Zone == "" { |
| + t.Errorf("callTitle in %v kept no zone", local) |
| + } |
| + } |
| +} |
| + |
| +// TestAutoTitleIsAFlagNotAReading: a meeting started with no title is |
| +// marked when it is made, and a meeting someone titled is not — even |
| +// one whose title reads exactly like an automatic one, which is why |
| +// this is a flag and not a pattern match on the words. |
| +func TestAutoTitleIsAFlagNotAReading(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := t.Context() |
| + auto, _, err := s.createCall(ctx, autoTitle{Text: "Call, 2 Oct 15:40", Zone: "Europe/Dublin"}, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + named, _, err := s.createMeeting(ctx, "Call, 2 Oct 15:40", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range []struct { |
| + m Meeting |
| + auto bool |
| + zone string |
| + }{{auto, true, "Europe/Dublin"}, {named, false, ""}} { |
| + got, ok, err := s.meetingBySqid(ctx, c.m.Sqid) |
| + if err != nil || !ok { |
| + t.Fatalf("read back %s: %v %v", c.m.Sqid, ok, err) |
| + } |
| + if got.AutoTitle != c.auto || got.TitleZone != c.zone { |
| + t.Errorf("meeting %s: auto %v zone %q, want %v %q", c.m.Sqid, got.AutoTitle, got.TitleZone, c.auto, c.zone) |
| + } |
| + if s.title(got) != "Call, 2 Oct 15:40" { |
| + t.Errorf("title read back as %q", s.title(got)) |
| + } |
| + } |
| +} |
diff --git a/internal/calls/handlers_room.go b/internal/calls/handlers_room.go| index bc06f8a..fef57a3 100644 |
| --- a/internal/calls/handlers_room.go |
| +++ b/internal/calls/handlers_room.go |
| @@ -70,7 +70,7 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) { |
| } |
| title := a.store.title(m) |
| data := a.page(r, onMeeting("", title), map[string]any{ |
| - "Sqid": m.Sqid, "Title": title, "Starts": a.fmtStart(m.StartsAt), |
| + "Sqid": m.Sqid, "Title": title, "Named": !m.AutoTitle, "Starts": a.fmtStart(m.StartsAt), |
| "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| // Floor and Relay are the same fact read twice, and both |
| // are rendering hints: Floor is §5.5's sentence for a |
| @@ -260,5 +260,7 @@ func (a *app) roomInfo(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - streamJSON(w, http.StatusOK, map[string]any{"title": a.store.title(m), "name": p.Name, "standing": st.String(), "floor": !a.relayConfigured()}) |
| + // named: a title someone chose, which the apps show over the call; a |
| + // title Calls wrote is left off (Meeting.AutoTitle). |
| + streamJSON(w, http.StatusOK, map[string]any{"title": a.store.title(m), "named": !m.AutoTitle, "name": p.Name, "standing": st.String(), "floor": !a.relayConfigured()}) |
| } |
diff --git a/internal/calls/migrations/0006_alter_meetings.sql b/internal/calls/migrations/0006_alter_meetings.sql| new file mode 100644 |
| index 0000000..1d395f1 |
| --- /dev/null |
| +++ b/internal/calls/migrations/0006_alter_meetings.sql |
| @@ -0,0 +1,4 @@ |
| +ALTER TABLE `meetings` ADD `auto_title` numeric NOT NULL DEFAULT false; |
| + |
| +ALTER TABLE `meetings` ADD `title_zone` text NOT NULL DEFAULT ""; |
| + |
diff --git a/internal/calls/migrations/schema.sql b/internal/calls/migrations/schema.sql| index e5030d4..a304144 100644 |
| --- a/internal/calls/migrations/schema.sql |
| +++ b/internal/calls/migrations/schema.sql |
| @@ -11,7 +11,7 @@ CREATE TABLE `meeting_defaults` (`id` integer PRIMARY KEY AUTOINCREMENT,`require |
| CREATE TABLE `meeting_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`token_hash` text NOT NULL,`created_by` text NOT NULL,`created_at` datetime,`revoked_at` datetime, `token_sealed` blob); |
| -CREATE TABLE `meetings` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text NOT NULL,`title_sealed` blob,`starts_at` datetime,`created_by` text NOT NULL,`created_at` datetime,`ended_at` datetime,`ended_by` text, `require_signin` numeric NOT NULL DEFAULT false, `waiting_room` numeric NOT NULL DEFAULT false); |
| +CREATE TABLE `meetings` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text NOT NULL,`title_sealed` blob,`starts_at` datetime,`created_by` text NOT NULL,`created_at` datetime,`ended_at` datetime,`ended_by` text, `require_signin` numeric NOT NULL DEFAULT false, `waiting_room` numeric NOT NULL DEFAULT false, `auto_title` numeric NOT NULL DEFAULT false, `title_zone` text NOT NULL DEFAULT ""); |
| CREATE TABLE `pending_joins` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`link_id` integer NOT NULL,`address_fingerprint` text NOT NULL,`created_at` datetime,`expires_at` datetime); |
diff --git a/internal/calls/models.go b/internal/calls/models.go| index 6dbf30c..091763c 100644 |
| --- a/internal/calls/models.go |
| +++ b/internal/calls/models.go |
| @@ -36,9 +36,20 @@ func (Person) TableName() string { return "persons" } |
| // Meeting is a room that may be entered. Its title is sealed; its |
| // times are plaintext because the list sorts on them. |
| +// |
| +// AutoTitle says the title is one Calls wrote ("Call, 2 Oct 15:40", |
| +// callTitle) rather than one a person chose: the apps show a chosen |
| +// title over the call and leave a written one off (2026-10-02). It is a |
| +// flag set where the title is made, not something read back out of the |
| +// title's words. TitleZone is the IANA zone an automatic title's time |
| +// is in — the starter's when their browser or app said, otherwise the |
| +// instance's — kept as data so the title itself can read cleanly |
| +// without naming it. |
| type Meeting struct { |
| - RequireSignin bool `gorm:"not null;default:false"` |
| - WaitingRoom bool `gorm:"not null;default:false"` |
| + RequireSignin bool `gorm:"not null;default:false"` |
| + WaitingRoom bool `gorm:"not null;default:false"` |
| + AutoTitle bool `gorm:"not null;default:false"` |
| + TitleZone string `gorm:"not null;default:''"` |
| ID int64 |
| Sqid string `gorm:"uniqueIndex;not null"` |
| TitleSealed []byte `gorm:"column:title_sealed"` |
diff --git a/internal/calls/native_auth.go b/internal/calls/native_auth.go| index be19dc7..4222aaa 100644 |
| --- a/internal/calls/native_auth.go |
| +++ b/internal/calls/native_auth.go |
| @@ -5,6 +5,7 @@ import ( |
| "crypto/sha256" |
| "encoding/base64" |
| "encoding/json" |
| + "html/template" |
| "io" |
| "net/http" |
| "net/url" |
| @@ -22,6 +23,7 @@ import ( |
| type nativeAuthorization struct { |
| mu sync.Mutex |
| pending map[string]*nativeRequest |
| + offers map[string]*nativeOffer |
| sessions *sessions.Sessions |
| } |
| type nativeRequest struct { |
| @@ -36,7 +38,7 @@ func (a *app) wireNativeAuth() error { |
| if err != nil { |
| return err |
| } |
| - a.nativeAuth = &nativeAuthorization{pending: make(map[string]*nativeRequest), sessions: s} |
| + a.nativeAuth = &nativeAuthorization{pending: make(map[string]*nativeRequest), offers: make(map[string]*nativeOffer), sessions: s} |
| return nil |
| } |
| func nativeRandom() string { |
| @@ -106,8 +108,12 @@ func (a *app) nativeRequest(w http.ResponseWriter, r *http.Request) { |
| var input struct { |
| Challenge string `json:"challenge"` |
| Target string `json:"target"` |
| + // Offer is the web's "Join in the app" (appOffer): a request |
| + // made for it is approved in the browser the person is already |
| + // signed in to, rather than in whichever one the app opens. |
| + Offer string `json:"offer"` |
| } |
| - if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Challenge) || !nativeTarget(input.Target) { |
| + if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Challenge) || !nativeTarget(input.Target) || (input.Offer != "" && !nativeToken(input.Offer)) { |
| http.Error(w, "invalid request", 400) |
| return |
| } |
| @@ -119,9 +125,27 @@ func (a *app) nativeRequest(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "try again later", 429) |
| return |
| } |
| + var offer *nativeOffer |
| + if input.Offer != "" { |
| + // One request per offer, for the meeting it was made for. A |
| + // second claim — the app asked twice, or something else on the |
| + // device answered the oficina-calls:// link first — is refused, |
| + // and the person's browser shows the code of the first, which |
| + // the app that lost cannot show. |
| + offer = n.offer(input.Offer) |
| + if offer == nil || offer.Request != "" || offer.Target != input.Target { |
| + http.Error(w, "this link has expired", http.StatusGone) |
| + return |
| + } |
| + } |
| id := nativeRandom() |
| n.pending[id] = &nativeRequest{Challenge: input.Challenge, Target: input.Target, Expires: time.Now().Add(5 * time.Minute)} |
| w.Header().Set("Content-Type", "application/json") |
| + if offer != nil { |
| + offer.Request = id |
| + json.NewEncoder(w).Encode(map[string]any{"id": id, "offer": true}) |
| + return |
| + } |
| json.NewEncoder(w).Encode(map[string]string{"id": id}) |
| } |
| func (a *app) nativeExchange(w http.ResponseWriter, r *http.Request) { |
| @@ -298,9 +322,15 @@ func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "expected JSON", http.StatusUnsupportedMediaType) |
| return |
| } |
| + // The app's zone, when it sends one, so the title is the time where |
| + // the starter is (callTitle). An app from before 0.4.7 sends "{}". |
| + var input struct { |
| + Zone string `json:"zone"` |
| + } |
| + _ = json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&input) |
| p, _ := currentPerson(r) |
| - title := callTitle(a.store.now(), nil, a.cfg.zone, a.cfg.Zone) |
| - m, _, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p)) |
| + title := callTitle(a.store.now(), callZone(input.Zone), a.cfg.zone) |
| + m, _, err := a.store.createCall(r.Context(), title, actorFor(p)) |
| if err != nil { |
| a.logger.Error("calls: native start meeting", "err", err) |
| http.Error(w, "cannot start a meeting", http.StatusInternalServerError) |
| @@ -308,7 +338,141 @@ func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) { |
| } |
| 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 names that zone, so an app in another one |
| - // does not name the meeting an hour it is not. |
| - json.NewEncoder(w).Encode(map[string]string{"sqid": m.Sqid, "title": title}) |
| + // the zone the app named, or the instance's. |
| + json.NewEncoder(w).Encode(map[string]string{"sqid": m.Sqid, "title": title.Text}) |
| +} |
| + |
| +// The web's "Join in the app" (2026-10-02). A person signed in on the |
| +// web — a member, a guest who signed in, a guest who only gave a name — |
| +// asks for the same meeting in the app. The app holds a session of its |
| +// own, or none, so the meeting's /m/ address alone means nothing to it: |
| +// it needs this person's standing, and the only safe way to give it that |
| +// is the handoff above, approved in a browser where this person is |
| +// signed in. The app opens the *default* browser for that, which need |
| +// not be the one they are in; a guest there has no way to sign in again. |
| +// |
| +// So the browser starts it instead. GET /m/{sqid}/app makes an offer |
| +// bound to this session and this meeting and shows a page that opens |
| +// oficina-calls://join?url=…&offer=…. The app claims the offer with an |
| +// ordinary /native/request carrying its challenge; this page, waiting, |
| +// then turns into /native/authorize for that request, in this browser, |
| +// with the code the app is showing. Approving there is what gives the |
| +// app a session — the same verifier exchange, the same one use, the same |
| +// five minutes, the same standing as the browser's session, and a guest |
| +// still only in the meeting their grant names. |
| +// |
| +// What the offer is not: a credential. It is in a URL handed to another |
| +// app, so on its own it buys nothing — a claim gets a request id, and |
| +// only the browser that made the offer can see which request claimed it |
| +// and approve it. |
| +type nativeOffer struct { |
| + Target string |
| + Owner [32]byte |
| + Expires time.Time |
| + Request string |
| +} |
| + |
| +// offer finds a live offer, sweeping expired ones. Under n.mu. |
| +func (n *nativeAuthorization) offer(id string) *nativeOffer { |
| + for k, v := range n.offers { |
| + if !time.Now().Before(v.Expires) { |
| + delete(n.offers, k) |
| + } |
| + } |
| + return n.offers[id] |
| +} |
| + |
| +// appOffer is GET /m/{sqid}/app, the holding screen's "Join in the app" |
| +// for a signed-in browser. Authorized like the holding screen itself. |
| +func (a *app) appOffer(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + m, _, _, ok := a.roomAccess(w, r) |
| + if !ok { |
| + return |
| + } |
| + c, err := r.Cookie(a.nativeAuth.sessions.CookieName()) |
| + if err != nil { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + owner := sha256.Sum256([]byte(c.Value)) |
| + n := a.nativeAuth |
| + n.mu.Lock() |
| + n.offer("") |
| + // One offer per browser session: asking again replaces it, so a |
| + // person pressing the button twice cannot fill the table. |
| + for k, v := range n.offers { |
| + if v.Owner == owner { |
| + delete(n.offers, k) |
| + } |
| + } |
| + if len(n.offers) >= 256 { |
| + n.mu.Unlock() |
| + a.ErrorPage(w, r, http.StatusTooManyRequests, "") |
| + return |
| + } |
| + id := nativeRandom() |
| + n.offers[id] = &nativeOffer{Target: "/m/" + m.Sqid, Owner: owner, Expires: time.Now().Add(5 * time.Minute)} |
| + n.mu.Unlock() |
| + http.Redirect(w, r, "/native/offer?o="+id, http.StatusSeeOther) |
| +} |
| + |
| +// offerPage is GET /native/offer?o=…: the page that waits for the app. |
| +// Before the app has claimed the offer it says to open the app and |
| +// offers the link; once it has, it is /native/authorize for that |
| +// request. room.js is not here: app-offer.js asks again with Accept: |
| +// application/json and follows "next", and with no script the page |
| +// refreshes itself (native.html). |
| +func (a *app) offerPage(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + id := r.URL.Query().Get("o") |
| + wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json") |
| + var owner [32]byte |
| + if c, err := r.Cookie(a.nativeAuth.sessions.CookieName()); err == nil { |
| + owner = sha256.Sum256([]byte(c.Value)) |
| + } |
| + n := a.nativeAuth |
| + n.mu.Lock() |
| + var offer nativeOffer |
| + found := false |
| + if nativeToken(id) { |
| + if o := n.offer(id); o != nil && o.Owner == owner { |
| + offer, found = *o, true |
| + } |
| + } |
| + n.mu.Unlock() |
| + if !found { |
| + if wantJSON { |
| + streamJSON(w, http.StatusNotFound, map[string]any{"expired": true}) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(http.StatusNotFound) |
| + render(w, "native", a.page(r, frame{parts: []string{"Join in the app"}, signinPage: true}, map[string]any{"Title": "This link to the app has expired", "Expired": true})) |
| + return |
| + } |
| + if offer.Request != "" { |
| + next := "/native/authorize?id=" + offer.Request |
| + if wantJSON { |
| + streamJSON(w, http.StatusOK, map[string]any{"next": next}) |
| + return |
| + } |
| + http.Redirect(w, r, next, http.StatusSeeOther) |
| + return |
| + } |
| + if wantJSON { |
| + streamJSON(w, http.StatusOK, map[string]any{"waiting": true}) |
| + return |
| + } |
| + render(w, "native", a.page(r, frame{parts: []string{"Join in the app"}, signinPage: true}, map[string]any{ |
| + "Title": "Join in the Calls app", "Waiting": true, |
| + "AppURL": nativeOfferURL(a.cfg.Origin+offer.Target, id), |
| + "Poll": "/native/offer?o=" + id, |
| + "BackHref": offer.Target + "/join", |
| + })) |
| +} |
| + |
| +// nativeOfferURL is the link that opens the app with an offer. |
| +func nativeOfferURL(meeting, offer string) template.URL { |
| + return template.URL("oficina-calls://join?url=" + url.QueryEscape(meeting) + "&offer=" + url.QueryEscape(offer)) |
| } |
diff --git a/internal/calls/native_auth_test.go b/internal/calls/native_auth_test.go| index f8d61ed..94c4589 100644 |
| --- a/internal/calls/native_auth_test.go |
| +++ b/internal/calls/native_auth_test.go |
| @@ -6,13 +6,17 @@ import ( |
| "encoding/json" |
| "net/http" |
| "net/http/httptest" |
| + "net/url" |
| "strings" |
| "testing" |
| "time" |
| + "amadan.net/oficina/calls/protocol" |
| "amadan.net/oficina/home/connect" |
| ) |
| +func protocolCode(id string) string { return protocol.AuthenticationCode(id) } |
| + |
| func nativeJSON(f *homeRouterFixture, path string, input any) *httptest.ResponseRecorder { |
| b, _ := json.Marshal(input) |
| r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(string(b))) |
| @@ -376,3 +380,134 @@ func TestNativeSessionReadsWhatTheAppReads(t *testing.T) { |
| t.Fatal("a revoked session followed an invitation", w.Code, w.Header().Get("Location")) |
| } |
| } |
| + |
| +// The web's "Join in the app" (2026-10-02): a guest who gave only a name |
| +// in this browser is handed to the app as themselves, approved here, |
| +// with no sign-in — the case the /m/ link could never do, because the |
| +// app's own browser had no session for a guest and no way to make one. |
| +func TestJoinInTheAppHandsThisBrowsersGuestToTheApp(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + m, token, err := f.app.store.createMeeting(t.Context(), "Guest call", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if w := f.do("POST", "/j/"+token+"/guest", "name=Visitor"); w.Code != 303 { |
| + t.Fatal(w.Code, w.Body.String()) |
| + } |
| + holding := f.do("GET", "/m/"+m.Sqid+"/join", "").Body.String() |
| + if !strings.Contains(holding, `href="/m/`+m.Sqid+`/app"`) { |
| + t.Fatal("the holding screen's Join in the app is not the handoff") |
| + } |
| + page := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/app", "")) |
| + offer := strings.TrimPrefix(page, "/native/offer?o=") |
| + if !nativeToken(offer) { |
| + t.Fatal("no offer", page) |
| + } |
| + w := f.do("GET", page, "") |
| + if w.Code != 200 || !strings.Contains(w.Body.String(), "oficina-calls://join?url="+url.QueryEscape(homeTestOrigin+"/m/"+m.Sqid)+"&offer="+offer) { |
| + t.Fatal("the waiting page does not open the app with the offer", w.Code, w.Body.String()) |
| + } |
| + if got := w.Header().Get("Cache-Control"); got != "no-store" { |
| + t.Fatal("cacheable", got) |
| + } |
| + // Somebody else's browser cannot watch the offer. |
| + other := httptest.NewRequest("GET", homeTestOrigin+page, nil) |
| + rec := httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, other) |
| + if rec.Code == 200 && strings.Contains(rec.Body.String(), offer) { |
| + t.Fatal("another browser sees the offer") |
| + } |
| + claim := func(target, offer string) (*httptest.ResponseRecorder, string) { |
| + v := nativeRandom() |
| + h := sha256.Sum256([]byte(v)) |
| + return nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": target, "offer": offer}), v |
| + } |
| + if w, _ := claim("/m/another", offer); w.Code != http.StatusGone { |
| + t.Fatal("an offer was claimed for another meeting", w.Code) |
| + } |
| + w, verifier := claim("/m/"+m.Sqid, offer) |
| + var request struct { |
| + ID string `json:"id"` |
| + Offer bool `json:"offer"` |
| + } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &request) != nil || !request.Offer { |
| + t.Fatal("claim", w.Code, w.Body.String()) |
| + } |
| + if w, _ := claim("/m/"+m.Sqid, offer); w.Code != http.StatusGone { |
| + t.Fatal("an offer was claimed twice", w.Code) |
| + } |
| + if dest := homeRedirect(t, f.do("GET", page, "")); dest != "/native/authorize?id="+request.ID { |
| + t.Fatal("the waiting page did not become the confirmation", dest) |
| + } |
| + poll := httptest.NewRequest("GET", homeTestOrigin+page, nil) |
| + poll.Header.Set("Accept", "application/json") |
| + for _, c := range f.cookies { |
| + poll.AddCookie(c) |
| + } |
| + rec = httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, poll) |
| + if !strings.Contains(rec.Body.String(), `"next":"/native/authorize?id=`+request.ID+`"`) { |
| + t.Fatal("the poll did not name the confirmation", rec.Body.String()) |
| + } |
| + if w := f.do("GET", "/native/authorize?id="+request.ID, ""); w.Code != 200 || !strings.Contains(w.Body.String(), protocolCode(request.ID)) { |
| + t.Fatal("confirmation", w.Code) |
| + } |
| + if w := nativeJSON(f, "/native/exchange", map[string]string{"id": request.ID, "verifier": verifier}); w.Code != 202 { |
| + t.Fatal("released before approval", w.Code) |
| + } |
| + f.do("POST", "/native/authorize?id="+request.ID, "") |
| + w = nativeJSON(f, "/native/exchange", map[string]string{"id": request.ID, "verifier": verifier}) |
| + var result map[string]string |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result["target"] != "/m/"+m.Sqid { |
| + t.Fatal("exchange", w.Code, w.Body.String()) |
| + } |
| + f.cookies = map[string]*http.Cookie{result["name"]: {Name: result["name"], Value: result["value"]}} |
| + if w := f.do("GET", "/m/"+m.Sqid+"/info", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"name":"Visitor"`) { |
| + t.Fatal("the app is not the same guest in the same meeting", w.Code, w.Body.String()) |
| + } |
| + if w := f.do("GET", "/settings", ""); w.Code != 404 { |
| + t.Fatal("the guest gained member authority", w.Code) |
| + } |
| +} |
| + |
| +// An offer expires with the five minutes every request has, and the page |
| +// says so rather than waiting for ever. |
| +func TestJoinInTheAppOfferExpires(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + m, _, err := f.app.store.createMeeting(t.Context(), "Expiry", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + page := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/app", "")) |
| + offer := strings.TrimPrefix(page, "/native/offer?o=") |
| + f.app.nativeAuth.offers[offer].Expires = time.Now().Add(-time.Second) |
| + if w := f.do("GET", page, ""); w.Code != 404 || !strings.Contains(w.Body.String(), "expired") { |
| + t.Fatal("expired offer", w.Code) |
| + } |
| + v := nativeRandom() |
| + h := sha256.Sum256([]byte(v)) |
| + if w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/m/" + m.Sqid, "offer": offer}); w.Code != http.StatusGone { |
| + t.Fatal("an expired offer was claimed", w.Code) |
| + } |
| +} |
| + |
| +// The apps' Start meeting names the device's zone, and the title is the |
| +// time there with no zone in the words (2026-10-02). |
| +func TestNativeStartMeetingTitlesInTheAppsZone(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + w := nativeMemberJSON(f, "/native/meetings/start", `{"zone":"Asia/Tokyo"}`) |
| + var out map[string]string |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil { |
| + t.Fatal(w.Code, w.Body.String()) |
| + } |
| + tokyo, _ := time.LoadLocation("Asia/Tokyo") |
| + if want := "Call, " + f.app.store.now().In(tokyo).Format("2 Jan 15:04"); out["title"] != want { |
| + t.Fatalf("title %q, want %q", out["title"], want) |
| + } |
| + m, ok, err := f.app.store.meetingBySqid(t.Context(), out["sqid"]) |
| + if err != nil || !ok || !m.AutoTitle || m.TitleZone != "Asia/Tokyo" { |
| + t.Fatalf("meeting %+v %v %v", m, ok, err) |
| + } |
| +} |
diff --git a/internal/calls/static/app-offer.js b/internal/calls/static/app-offer.js| new file mode 100644 |
| index 0000000..acc4094 |
| --- /dev/null |
| +++ b/internal/calls/static/app-offer.js |
| @@ -0,0 +1,45 @@ |
| +// app-offer.js: the web's "Join in the app" page (native.html, Waiting). |
| +// |
| +// Opens the Calls app once, with the offer in the link, and then asks |
| +// the server every two seconds whether the app has taken it up. When it |
| +// has, the answer names the confirmation page for the app's request and |
| +// this tab goes there — this browser, where the person is signed in, so |
| +// the confirmation needs no sign-in. An expired offer says so. With no |
| +// script none of this runs: the button opens the app and the page |
| +// refreshes itself (a <noscript> refresh), arriving at the same place. |
| +(() => { |
| + 'use strict'; |
| + const box = document.getElementById('app-offer'); |
| + if (!box) return; |
| + const status = document.getElementById('app-offer-status'); |
| + const app = box.dataset.app; |
| + const poll = box.dataset.poll; |
| + const key = 'calls.appOffer:' + poll; |
| + let opened = false; |
| + try { opened = sessionStorage.getItem(key) === '1'; } catch { /* private mode */ } |
| + if (!opened && app) { |
| + try { sessionStorage.setItem(key, '1'); } catch { /* this page only */ } |
| + // A browser that wants a click first ignores this; the button is there. |
| + location.href = app; |
| + } |
| + let stopped = false; |
| + async function ask() { |
| + if (stopped) return; |
| + try { |
| + const res = await fetch(poll, { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' }); |
| + const body = await res.json().catch(() => ({})); |
| + if (body.next) { |
| + stopped = true; |
| + location.href = body.next; |
| + return; |
| + } |
| + if (res.status === 404 || body.expired) { |
| + stopped = true; |
| + if (status) status.textContent = 'This link to the app has expired. Go back to the meeting and choose Join in the app again.'; |
| + return; |
| + } |
| + } catch { /* offline for a moment: ask again */ } |
| + setTimeout(ask, 2000); |
| + } |
| + setTimeout(ask, 1500); |
| +})(); |
diff --git a/internal/calls/static/call-window.js b/internal/calls/static/call-window.js| index 58c2440..1592198 100644 |
| --- a/internal/calls/static/call-window.js |
| +++ b/internal/calls/static/call-window.js |
| @@ -11,8 +11,8 @@ |
| // them, and 3 s without it hides them; leaving the window hides them |
| // 1.5 s later (never later than they were due anyway). A tap on the |
| // picture toggles them. They stay while something holds them: the |
| -// pointer over the bar or the title, a popover, the chat, the title's |
| -// menu or the phone's sheet open, keyboard focus in them, the call over |
| +// pointer over the bar, a popover (More among them), the chat or the |
| +// phone's sheet open, keyboard focus in them, the call over |
| // (its way out is in the notices, and the bar with it), and — because a |
| // page cannot tell whether a screen reader is running — always, for |
| // anyone who has asked for reduced motion. Tab brings them back. Space |
| @@ -113,7 +113,6 @@ |
| const bar = $('controls'); |
| const top = $('call-top'); |
| if (!win || !room || !bar || !top) return; |
| - const nav = $('call-nav'); |
| const sheet = $('call-sheet'); |
| const moreControls = $('more-controls'); |
| const leave = $('leave'); |
| @@ -167,8 +166,8 @@ |
| step({ type: 'hold', hold: name, on: !!on }); |
| } |
| - // Over the bar or the title, the pointer holds them (the Mac's bar and |
| - // traffic lights). |
| + // Over the bar, the pointer holds them (the Mac's bar and traffic |
| + // lights). The title takes no pointer: it is only words. |
| const overChrome = (el) => !!el && (bar.contains(el) || top.contains(el)); |
| const inPopover = (el) => !!el && popovers.some((p) => p.panel.contains(el)); |
| @@ -187,7 +186,7 @@ |
| // does nothing else — its click is swallowed. A mouse always moves |
| // first, so its controls are up by the time it presses. |
| const INTERACTIVE = 'button, a, summary, input, select, textarea, label, .calls-popover, .calls-chat-overlay, ' + |
| - '.calls-nav-menu, .calls-sheet, .calls-notices > *, .calls-tile-tools, .calls-tile-menu'; |
| + '.calls-sheet, .calls-notices > *, .calls-tile-tools, .calls-tile-menu'; |
| let swallow = false; |
| win.addEventListener('pointerdown', (ev) => { |
| const target = ev.target instanceof Element ? ev.target : null; |
| @@ -225,7 +224,7 @@ |
| document.addEventListener('focusin', focusCheck); |
| document.addEventListener('focusout', () => setTimeout(focusCheck, 0)); |
| - // ---- popovers, the title's menu and the phone's sheet ---- |
| + // ---- popovers and the phone's sheet ---- |
| function placePopover(p) { |
| if (p.panel.hidden) return; |
| @@ -298,15 +297,13 @@ |
| }); |
| window.addEventListener('resize', () => { for (const p of popovers) placePopover(p); }); |
| - // A press anywhere else closes what is open: a popover, the title's |
| - // menu, the sheet. Chat is a panel, not a popover, and stays. |
| + // A press anywhere else closes what is open: a popover, the sheet. Chat is a panel, not a popover, and stays. |
| document.addEventListener('pointerdown', (ev) => { |
| const t = ev.target instanceof Node ? ev.target : null; |
| if (!t) return; |
| for (const p of popovers) { |
| if (!p.panel.hidden && !p.panel.contains(t) && !p.toggle.contains(t)) setPopover(p, false); |
| } |
| - if (nav && nav.open && !nav.contains(t)) nav.open = false; |
| if (sheet && sheet.hasAttribute('data-open') && !sheet.contains(t) && !(moreControls && moreControls.contains(t)) && |
| !popovers.some((p) => p.panel.contains(t))) setSheet(false); |
| }, true); |
| @@ -323,12 +320,6 @@ |
| open.toggle.focus(); |
| return; |
| } |
| - if (nav && nav.open) { |
| - ev.preventDefault(); |
| - nav.open = false; |
| - nav.querySelector('summary')?.focus(); |
| - return; |
| - } |
| if (sheet && sheet.hasAttribute('data-open')) { |
| ev.preventDefault(); |
| setSheet(false); |
| @@ -340,13 +331,12 @@ |
| function menusCheck() { |
| const open = popovers.some((p) => !p.panel.hidden) || (chatOverlay && !chatOverlay.hidden) || |
| (tileMenu && !tileMenu.hidden) || |
| - (nav && nav.open) || (sheet && sheet.hasAttribute('data-open')); |
| + (sheet && sheet.hasAttribute('data-open')); |
| hold('menuOpen', open); |
| } |
| const watch = (el, attrs) => { if (el) new MutationObserver(menusCheck).observe(el, { attributes: true, attributeFilter: attrs }); }; |
| watch(chatOverlay, ['hidden']); |
| watch(tileMenu, ['hidden']); |
| - watch(nav, ['open']); |
| watch(sheet, ['data-open']); |
| // The call is over: Leave is disabled and the way out is in the notices. |
| if (leave) { |
| @@ -359,34 +349,12 @@ |
| motionCheck(); |
| menusCheck(); |
| - // ---- the title's status, and the relay notice ---- |
| - |
| - // callState is the pill's few words for how the call is going, as the |
| - // Mac's pill has them: the full sentence, and its way out when there |
| - // is one, is the status notice under it. Lifted by name in |
| - // room-window.test.mjs. |
| - function callState(s, status) { |
| - if (!s) return 'Connecting'; |
| - if (s.terminal) return 'Call ended'; |
| - if (/full/i.test(status || '')) return 'The room is full'; |
| - if (!s.admitted) return /waiting/i.test(status || '') ? 'Waiting to be let in' : 'Connecting'; |
| - // §15.23: only ever a problem that has lasted (room.js's watch). |
| - if (s.reconnect && s.reconnect.call) return 'Reconnecting'; |
| - return 'In the meeting'; |
| - } |
| - const stateLine = $('call-state'); |
| - const statusEl = $('status'); |
| - function drawState() { |
| - if (!stateLine || !window.meet || typeof window.meet.state !== 'function') return; |
| - let st = null; |
| - try { st = window.meet.state(); } catch { st = null; } |
| - const text = callState(st, statusEl ? statusEl.textContent : ''); |
| - if (stateLine.textContent !== text) stateLine.textContent = text; |
| - stateLine.hidden = false; |
| - } |
| - if (statusEl) new MutationObserver(drawState).observe(statusEl, { childList: true, characterData: true, subtree: true }); |
| - setInterval(drawState, 1000); |
| - drawState(); |
| + // ---- the relay notice ---- |
| + |
| + // There is no title pill (2026-10-02): how the call is going is the |
| + // status notice room.js keeps (#status) — Connecting, Waiting to be let |
| + // in, The room is full, Reconnecting, the call over with its way out — |
| + // and a pill saying it again in fewer words was clutter. |
| // §5.5's relay notice: shown in the first room a tab's session opens, |
| // until it is put away; not again in that session. |
| @@ -402,8 +370,9 @@ |
| $('floor-dismiss')?.addEventListener('click', () => { floor.hidden = true; }); |
| } |
| - // The notices sit under the title, however tall it grows; it keeps its |
| - // place while faded, so they do not jump as the controls come and go. |
| + // The notices sit under the title strip — none for a title Calls wrote, |
| + // which is not shown — and it keeps its place while faded, so they do |
| + // not jump as the controls come and go. |
| const titleHeight = () => win.style.setProperty('--calls-title-h', Math.ceil(top.offsetHeight) + 'px'); |
| if (window.ResizeObserver) new ResizeObserver(titleHeight).observe(top); |
| titleHeight(); |
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css| index aa85ff6..da75362 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -300,75 +300,72 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| .calls-window[data-chrome='hidden'][data-pointer-inside], |
| .calls-window[data-chrome='hidden'][data-pointer-inside] * { cursor: none !important; } |
| -/* ── The title, with the page's own navigation in its menu ── */ |
| +/* ── The title: a window title, only for a title someone chose ── */ |
| +/* No pill (2026-10-02). A chosen title sits where a Mac window's title |
| + would, centred in the strip across the top of the window, plain white |
| + over a dark scrim that fades with it and the controls (the Mac's |
| + topShade). A title Calls wrote is the page's heading for a screen |
| + reader and nothing on screen, and the strip then takes no room, so |
| + the notices sit at the top. noscript.css turns all of this back into |
| + a plain block for a browser with no script. */ |
| .calls-top { |
| position: absolute; |
| z-index: 4; |
| - inset-block-start: 12px; |
| - inset-inline-start: 12px; |
| + inset-block-start: 0; |
| + inset-inline: 0; |
| display: flex; |
| - align-items: flex-start; |
| - gap: 6px; |
| - max-inline-size: min(420px, calc(100% - 24px)); |
| - padding: 6px 14px 7px 6px; |
| - border-radius: 14px; |
| + align-items: center; |
| + justify-content: center; |
| + block-size: 44px; |
| + padding: 0 72px; |
| + pointer-events: none; |
| +} |
| +.calls-top-named::before { |
| + content: ''; |
| + position: absolute; |
| + z-index: -1; |
| + inset: 0 0 auto; |
| + block-size: 112px; |
| + /* Dark enough through the strip itself for white text over a white |
| + picture (4.5:1, the window scenario's contrast check), then fading. */ |
| + background: linear-gradient(rgb(0 0 0 / 0.72) 0, rgb(0 0 0 / 0.62) 44px, rgb(0 0 0 / 0)); |
| + pointer-events: none; |
| } |
| -.calls-top-text { min-inline-size: 0; padding-block-start: 2px; } |
| .calls-top h1 { |
| + min-inline-size: 0; |
| + max-inline-size: 100%; |
| margin: 0; |
| - font-size: 0.9375rem; |
| - font-weight: 650; |
| + font-size: 0.875rem; |
| + font-weight: 600; |
| line-height: 1.3; |
| letter-spacing: 0; |
| color: #fff; |
| + text-shadow: 0 1px 2px rgb(0 0 0 / 0.5); |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| +.calls-top:not(.calls-top-named) { block-size: 0; padding: 0; } |
| +.calls-top:not(.calls-top-named) h1 { |
| + position: absolute; |
| + inline-size: 1px; |
| + block-size: 1px; |
| + margin: -1px; |
| + overflow: hidden; |
| + clip-path: inset(50%); |
| +} |
| .calls-top-line { |
| margin: 1px 0 0; |
| font-size: 0.8125rem; |
| line-height: 1.35; |
| color: rgb(255 255 255 / 0.85); |
| } |
| -.calls-top .calls-floor { |
| - background: none; |
| - padding: 0; |
| - border-radius: 0; |
| - color: rgb(255 255 255 / 0.85); |
| - font-size: 0.8125rem; |
| -} |
| -.calls-nav { position: relative; flex: none; } |
| -.calls-nav > summary { |
| - display: grid; |
| - place-items: center; |
| - inline-size: 32px; |
| - block-size: 32px; |
| - border-radius: 999px; |
| - cursor: pointer; |
| - list-style: none; |
| - color: #fff; |
| -} |
| -.calls-nav > summary::-webkit-details-marker { display: none; } |
| -.calls-nav > summary:hover { background: rgb(255 255 255 / 0.1); } |
| -.calls-nav[open] > summary { background: #f0f0f0; color: #1a1a1a; } |
| -.calls-nav > summary .icon { inline-size: 18px; block-size: 18px; } |
| -.calls-nav-menu { |
| - position: absolute; |
| - z-index: 8; |
| - inset-block-start: calc(100% + 8px); |
| - inset-inline-start: 0; |
| - display: flex; |
| - flex-direction: column; |
| - min-inline-size: 15rem; |
| - padding: 6px; |
| - border-radius: 14px; |
| - background: var(--calls-panel); |
| - box-shadow: 0 8px 28px rgb(0 0 0 / 0.4); |
| -} |
| -.calls-nav-menu a, |
| -.calls-nav-menu button, |
| -.calls-menu a { |
| + |
| +/* The More menu's account part: the standing line, who is signed in, |
| + the headings and separators of the account menu (estilo menu.css), |
| + drawn for the dark panel: same order, same words. */ |
| +.calls-menu a, |
| +.calls-menu form button { |
| display: flex; |
| align-items: center; |
| min-block-size: 40px; |
| @@ -384,26 +381,24 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| cursor: pointer; |
| inline-size: 100%; |
| } |
| -.calls-nav-menu a:hover, |
| -.calls-nav-menu button:hover, |
| -.calls-menu a:hover { background: rgb(255 255 255 / 0.1); } |
| -.calls-nav-menu form { margin: 0; } |
| -.calls-nav-standing { |
| +.calls-menu a:hover, |
| +.calls-menu form button:hover { background: rgb(255 255 255 / 0.1); } |
| +.calls-menu form { margin: 0; } |
| +.calls-menu-standing { |
| margin: 0; |
| padding: 6px 12px 8px; |
| + max-inline-size: 18rem; |
| color: rgb(255 255 255 / 0.85); |
| font-size: 0.8125rem; |
| line-height: 1.4; |
| } |
| -/* The account menu's separators, headings and identity row (estilo |
| - menu.css), drawn for the dark glass: same order, same words. */ |
| -.calls-nav-menu hr { |
| +.calls-menu hr { |
| inline-size: 100%; |
| margin: 4px 0; |
| border: 0; |
| border-block-start: 1px solid rgb(255 255 255 / 0.12); |
| } |
| -.calls-nav-heading { |
| +.calls-menu-heading { |
| display: block; |
| margin: 0; |
| padding: 6px 12px 2px; |
| @@ -411,8 +406,8 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| font-size: 0.75rem; |
| font-weight: 600; |
| } |
| -.calls-nav-who { margin: 0; padding-block-end: 6px; } |
| -.calls-nav-address { |
| +.calls-menu-who { margin: 0; padding-block-end: 6px; } |
| +.calls-menu-address { |
| display: block; |
| padding: 0 12px; |
| color: #fff; |
| @@ -420,11 +415,6 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| overflow-wrap: anywhere; |
| } |
| -/* The title's menu opens over the notices column. .calls-top is a |
| - stacking context at 4 and the notices sit at 5, so without this the |
| - open menu drew under the relay notice on a phone (r3 audit C3). */ |
| -.calls-top:has(#call-nav[open]) { z-index: 9; } |
| - |
| /* ── With no script: the page's sentence where the picture would be ── */ |
| .calls-scriptless { |
| position: absolute; |
| @@ -1427,7 +1417,7 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| inset-block-start: 8px; |
| inline-size: auto; |
| } |
| - .calls-top { padding-inline-end: 12px; } |
| + .calls-top { padding-inline: 16px; } |
| .calls-window #tiles > .calls-tile-pip { |
| inline-size: clamp(88px, 28cqw, 150px); |
| aspect-ratio: 3 / 4; |
diff --git a/internal/calls/static/noscript.css b/internal/calls/static/noscript.css| index d11630f..45c7701 100644 |
| --- a/internal/calls/static/noscript.css |
| +++ b/internal/calls/static/noscript.css |
| @@ -2,3 +2,34 @@ |
| is in the layout from the first paint for script, has nothing to offer |
| without it (AGENTS.md: browser media is one of the two exceptions). */ |
| #devices { display: none; } |
| + |
| +/* The call room with no script (room.html): the header is a plain block |
| + in the top corner — the title whichever kind it is, who you are in |
| + the call, the floor notice and the page's navigation — rather than |
| + the scripted call's centred title strip, which shows only a chosen |
| + title and fades with controls a scriptless page does not have. */ |
| +.calls-window .calls-top { |
| + inset-block-start: 12px; |
| + inset-inline: 12px auto; |
| + display: block; |
| + block-size: auto; |
| + max-inline-size: min(420px, calc(100% - 24px)); |
| + padding: 10px 14px; |
| + border-radius: 14px; |
| + background: rgb(18 18 18 / 0.92); |
| + pointer-events: auto; |
| +} |
| +.calls-window .calls-top-named::before { display: none; } |
| +.calls-window .calls-top h1, |
| +.calls-window .calls-top:not(.calls-top-named) h1 { |
| + position: static; |
| + inline-size: auto; |
| + block-size: auto; |
| + margin: 0; |
| + clip-path: none; |
| + white-space: normal; |
| + text-shadow: none; |
| +} |
| +.calls-window .calls-top-links { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; margin: 6px 0 0; font-size: 0.875rem; } |
| +.calls-window .calls-top-links form { margin: 0; } |
| +.calls-window .calls-top-links button { padding: 0; border: 0; background: none; color: #fff; font: inherit; text-decoration: underline; cursor: pointer; } |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index ee8aefe..75abb55 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -5952,31 +5952,30 @@ |
| statusEl.toggleAttribute('data-quiet', !!quiet); |
| } |
| + // statusLine is what the status notice says for how the call is going, |
| + // and whether it is quiet (fades with the controls): the one place the |
| + // call's state is put into words since the title pill went (2026-10-02). |
| + // Pure, so test/browser/room-window.test.mjs lifts it by name and holds |
| + // every state the pill used to carry — connecting, waiting to be let |
| + // in, full, reconnecting — to a notice. The call being over is said by |
| + // stop(), with its way out, and renderStatus says nothing after it. |
| + function statusLine(s, reconnectCall, others, member) { |
| + if (s.full) return ['The room is full.', false]; |
| + // A member is admitted by its own join, so between hello and the |
| + // roster it is connecting, not waiting. Only a guest that has |
| + // knocked is actually waiting for anyone. |
| + if (!s.admitted) { |
| + return [s.peer && s.joined && !member ? 'Waiting for someone to let you in.' : 'Connecting to the call.', false]; |
| + } |
| + if (reconnectCall) return ['Reconnecting…', false]; |
| + if (!others) return ['You are the only one here.', true]; |
| + return ['', false]; |
| + } |
| + |
| function renderStatus() { |
| if (state.stopped) return; |
| - if (state.full) { |
| - say('The room is full.'); |
| - return; |
| - } |
| - if (!state.admitted) { |
| - // A member is admitted by its own join, so between hello and the |
| - // roster it is connecting, not waiting. Only a guest that has |
| - // knocked is actually waiting for anyone. |
| - say(state.peer && state.joined && !isMember() |
| - ? 'Waiting for someone to let you in.' |
| - : 'Connecting to the call.'); |
| - return; |
| - } |
| - if (reconnect.call) { |
| - say('Reconnecting…'); |
| - return; |
| - } |
| - const others = pcs.size; |
| - if (!others) { |
| - say('You are the only one here.', true); |
| - return; |
| - } |
| - say(''); |
| + const [text, quiet] = statusLine(state, reconnect.call, pcs.size, isMember()); |
| + say(text, quiet); |
| } |
| function isMember() { |
diff --git a/internal/calls/store.go b/internal/calls/store.go| index 6f69cad..f2d3d0e 100644 |
| --- a/internal/calls/store.go |
| +++ b/internal/calls/store.go |
| @@ -103,6 +103,17 @@ func (s *store) title(m Meeting) string { |
| // createMeeting creates the meeting and its first link in one |
| // transaction. The token is sealed for subsequent member sharing. |
| func (s *store) createMeeting(ctx context.Context, title string, startsAt *time.Time, actor string) (Meeting, string, error) { |
| + return s.insertMeeting(ctx, title, false, "", startsAt, actor) |
| +} |
| + |
| +// createCall is createMeeting for a meeting with no title of its own — |
| +// the list's Start a call and the apps' Start meeting: the title is the |
| +// one Calls wrote, and the meeting says so (Meeting.AutoTitle). |
| +func (s *store) createCall(ctx context.Context, title autoTitle, actor string) (Meeting, string, error) { |
| + return s.insertMeeting(ctx, title.Text, true, title.Zone, nil, actor) |
| +} |
| + |
| +func (s *store) insertMeeting(ctx context.Context, title string, auto bool, zone string, startsAt *time.Time, actor string) (Meeting, string, error) { |
| sealed, err := s.sealer.SealString(seal.PurposeMeetingTitle, title) |
| if err != nil { |
| return Meeting{}, "", err |
| @@ -126,7 +137,7 @@ func (s *store) createMeeting(ctx context.Context, title string, startsAt *time. |
| if err != nil { |
| return err |
| } |
| - m = Meeting{RequireSignin: defaults.RequireSignin, WaitingRoom: defaults.WaitingRoom, Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()} |
| + m = Meeting{RequireSignin: defaults.RequireSignin, WaitingRoom: defaults.WaitingRoom, AutoTitle: auto, TitleZone: zone, Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()} |
| res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&m) |
| if res.Error != nil { |
| return res.Error |
diff --git a/internal/calls/templates/join.html b/internal/calls/templates/join.html| index 820f588..7de6f97 100644 |
| --- a/internal/calls/templates/join.html |
| +++ b/internal/calls/templates/join.html |
| @@ -76,7 +76,7 @@ |
| </div> |
| </div> |
| <div class="calls-holding-more"> |
| - <p><a href="{{.NativeURL}}" rst-btn>Join in the app</a> <span class="calls-option-help">The Calls app must be installed to open it.</span></p> |
| + <p><a href="{{.AppHref}}" rst-btn>Join in the app</a> <span class="calls-option-help">The Calls app must be installed to open it.</span></p> |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">{{icon "share"}}Share link</button></form> |
| <p class="calls-holding-links"><a href="/meetings/{{.Sqid}}/settings">Meeting settings</a> · <a href="/meetings/{{.Sqid}}/end">End meeting for everyone</a></p>{{end}} |
| </div> |
diff --git a/internal/calls/templates/native.html b/internal/calls/templates/native.html| index da04f0a..43a4bb7 100644 |
| --- a/internal/calls/templates/native.html |
| +++ b/internal/calls/templates/native.html |
| @@ -1,8 +1,24 @@ |
| +{{define "head"}}{{if .Waiting}}<script defer src="{{asset "static/app-offer.js"}}"></script> |
| +<noscript><meta http-equiv="refresh" content="3"></noscript>{{end}}{{end}} |
| {{define "content"}} |
| {{template "page-header" dict "Title" .Title}} |
| <div rst-box> |
| {{if .Done}} |
| <p>Return to the Calls app to join your meeting. You can close this tab.</p> |
| +{{else if .Expired}} |
| +<p>Go back to the meeting and choose Join in the app again.</p> |
| +<p><a href="/">Meetings</a></p> |
| +{{else if .Waiting}} |
| +{{/* The web's Join in the app (appOffer): this page waits for the app |
| + to ask, then becomes the confirmation for it, here, where you are |
| + signed in. app-offer.js opens the app once and asks every couple of |
| + seconds; with no script the page refreshes itself every three. */}} |
| +<div id="app-offer" data-app="{{.AppURL}}" data-poll="{{.Poll}}"> |
| +<p>Open the Calls app to join this meeting there as you. When the app shows a code, this page will ask you to check it.</p> |
| +<p><a id="open-app" href="{{.AppURL}}" rst-btn="primary lg">Open the Calls app</a></p> |
| +<p id="app-offer-status" role="status">Waiting for the app…</p> |
| +<p><a href="{{.BackHref}}">Back to the meeting</a></p> |
| +</div> |
| {{else}} |
| <p>Check that this code matches the one in your Calls app: <strong class="rst-mono">{{.Code}}</strong></p> |
| {{if .Guest}} |
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html| index 415200a..45cfda7 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -1,4 +1,5 @@ |
| {{define "head"}}<link rel="stylesheet" href="{{asset "static/calls.css"}}"> |
| +<noscript><link rel="stylesheet" href="{{asset "static/noscript.css"}}"></noscript> |
| <link rel="stylesheet" href="{{asset "static/chat.css"}}"> |
| <script defer src="{{asset "static/chat.js"}}"></script> |
| <script defer src="{{asset "static/room.js"}}"></script> |
| @@ -13,40 +14,30 @@ |
| phone), with the picture filling it and everything else over the |
| picture, as the Mac's full-window call has it (MacFullWindowCall). |
| - What the page shell carried — Meetings, Settings, Help, the |
| - account and Sign out — is in the title's menu: a native disclosure, |
| - so it opens with no script at all. The title, the standing line and |
| - the floor notice are server-rendered and outside #room, so a |
| - scriptless browser has them inside the window; it has the |
| - paragraph saying a call needs JavaScript, and the page's links, |
| - where the picture would be. */}} |
| + No title pill (2026-10-02, Paul approved): how the call is going is |
| + said by the notices, and a pill saying it again was clutter. A title |
| + someone chose is shown the way a Mac window shows its title — plain, |
| + centred in the strip across the top, over a dark scrim, fading with |
| + the controls. A title Calls wrote ("Call, 2 Oct 15:40") is not shown |
| + at all; it stays the page's heading for a screen reader and its |
| + <title>. What the pill's ☰ menu carried — who you are, Meetings, |
| + Settings, Help, Sign out — is in the bar's ⋯ More menu. |
| + |
| + With no script the header is a plain block instead (noscript.css): |
| + the title whichever kind it is, the standing line, the floor notice |
| + and the page's navigation, all server-rendered (AGENTS.md). */}} |
| {{define "body"}} |
| <main id="main" class="calls-window" aria-labelledby="call-title"> |
| -<header id="call-top" class="calls-top calls-glass calls-fades"> |
| - <details id="call-nav" class="calls-nav"> |
| - <summary aria-label="Menu" title="Meetings, help and your account">{{icon "menu"}}</summary> |
| - {{/* The account menu's items, in its order and its words (chrome.go, |
| - roomMenu): who is signed in, the team's pages, Help → Open |
| - source, the one Sign out. Dark, because it is over the picture. */}} |
| - <nav class="calls-nav-menu" aria-label="Calls"> |
| - <p class="calls-nav-standing">You’re in this call as {{.Name}}{{if eq .Standing "guest"}}, a guest{{end}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p> |
| - {{with .Menu.Identity}}<hr><p class="calls-nav-who"><span class="calls-nav-heading">Signed in as</span><span class="calls-nav-address">{{.}}</span></p>{{end}} |
| - {{if .Menu.Nav}}<hr>{{range .Menu.Nav}}<a href="{{.Href}}">{{.Label}}</a>{{end}}{{end}} |
| - <hr><p class="calls-nav-heading">Help</p> |
| - {{range .Menu.Help}}<a href="{{.Href}}">{{.Label}}</a>{{end}} |
| - {{with .Menu.SignOut}}<hr><form method="post" action="{{.Action}}"><button type="submit">{{.Label}}</button></form>{{end}} |
| +<header id="call-top" class="calls-top calls-fades{{if .Named}} calls-top-named{{end}}"> |
| + <h1 id="call-title">{{.Title}}</h1> |
| + <noscript> |
| + <p class="calls-top-line">You’re in this call as {{.Name}}{{if eq .Standing "guest"}}, a guest{{end}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p> |
| + {{if .Floor}}<p class="calls-top-line calls-floor">Calls outside your network may not connect yet.</p>{{end}} |
| + <nav class="calls-top-nav" aria-label="Calls"> |
| + {{with .Menu.Identity}}<p class="calls-top-line">Signed in as {{.}}</p>{{end}} |
| + <p class="calls-top-links">{{range .Menu.Nav}}<a href="{{.Href}}">{{.Label}}</a> {{end}}{{range .Menu.Help}}<a href="{{.Href}}">{{.Label}}</a> {{end}}{{with .Menu.SignOut}}<form method="post" action="{{.Action}}"><button type="submit">{{.Label}}</button></form>{{end}}</p> |
| </nav> |
| - </details> |
| - <div class="calls-top-text"> |
| - <h1 id="call-title">{{.Title}}</h1> |
| - {{/* How the call is going, in a few words, as the Mac's pill has |
| - it: call-window.js keeps it. The standing line is in the menu |
| - and the relay notice is one of the notices; with no script |
| - neither has anywhere else to be, so both are here in full. */}} |
| - <p id="call-state" class="calls-top-line" hidden></p> |
| - <noscript><p class="calls-top-line">You’re in this call as {{.Name}}{{if eq .Standing "guest"}}, a guest{{end}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p> |
| - {{if .Floor}}<p class="calls-top-line calls-floor">Calls outside your network may not connect yet.</p>{{end}}</noscript> |
| - </div> |
| + </noscript> |
| </header> |
| {{/* The Share form appears twice on purpose, and never at the same |
| @@ -59,7 +50,6 @@ |
| script it is in the bar's More menu, inside #room. */}} |
| <noscript><div class="calls-scriptless"> |
| <p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p> |
| - <p class="calls-scriptless-links">{{if .IsMember}}<a href="/">Meetings</a> {{end}}<a href="/help/open-source">Open source</a></p> |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form> |
| <p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End meeting for everyone</a></p>{{end}} |
| </div></noscript> |
| @@ -133,7 +123,7 @@ |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link" data-tip="Share meeting link">{{icon "share"}}<span class="calls-bar-label">Share link</span></button></form>{{end}} |
| <span class="calls-bar-spacer" aria-hidden="true"></span> |
| <button type="button" id="toggle-chat" rst-btn aria-expanded="false" aria-controls="chat-overlay" hidden data-tip="Chat">{{icon "messages-square"}}<span class="calls-bar-label">Chat</span><span id="chat-unread" class="calls-badge" aria-hidden="true"></span></button> |
| - {{if .IsMember}}<button type="button" id="call-more-toggle" rst-btn aria-expanded="false" aria-controls="call-more" aria-label="More" data-tip="More">{{icon "ellipsis"}}<span class="calls-bar-label">More</span></button>{{end}} |
| + <button type="button" id="call-more-toggle" rst-btn aria-expanded="false" aria-controls="call-more" aria-label="More" data-tip="More">{{icon "ellipsis"}}<span class="calls-bar-label">More</span></button> |
| </div> |
| <button type="button" id="leave" rst-btn="danger" data-tip="Leave meeting">{{icon "phone-down"}}<span class="calls-bar-label">Leave</span></button> |
| </div> |
| @@ -181,12 +171,23 @@ |
| was copied. */}} |
| <button type="button" id="copy-diagnostics" rst-btn="ghost" class="calls-copy-diagnostics">Copy call diagnostics</button> |
| </div> |
| - {{/* Ending the meeting for everyone is destructive, so it is a link to |
| - its own confirm page (AGENTS.md), opened in a new tab so that |
| - following it does not leave the call. */}} |
| - {{if .IsMember}}<div id="call-more" class="calls-popover calls-menu" role="group" aria-label="More" hidden> |
| - <a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener" class="calls-menu-danger">End meeting for everyone</a> |
| - </div>{{end}} |
| + {{/* More: who you are in this call, then the account menu's items in |
| + its order and its words (chrome.go, roomMenu) — Meetings, |
| + Settings, Help → Open source, the one Sign out — which the title's |
| + ☰ menu carried until 2026-10-02. Ending the meeting for everyone |
| + is last, for a member: destructive, so a link to its own confirm |
| + page (AGENTS.md), opened in a new tab so that following it does |
| + not leave the call. With no script the header above has the same |
| + navigation. */}} |
| + <div id="call-more" class="calls-popover calls-menu" role="group" aria-label="More" hidden> |
| + <p class="calls-menu-standing">You’re in this call as {{.Name}}{{if eq .Standing "guest"}}, a guest{{end}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p> |
| + {{with .Menu.Identity}}<hr><p class="calls-menu-who"><span class="calls-menu-heading">Signed in as</span><span class="calls-menu-address">{{.}}</span></p>{{end}} |
| + {{if .Menu.Nav}}<hr>{{range .Menu.Nav}}<a href="{{.Href}}">{{.Label}}</a>{{end}}{{end}} |
| + <hr><p class="calls-menu-heading">Help</p> |
| + {{range .Menu.Help}}<a href="{{.Href}}">{{.Label}}</a>{{end}} |
| + {{with .Menu.SignOut}}<hr><form method="post" action="{{.Action}}"><button type="submit">{{.Label}}</button></form>{{end}} |
| + {{if .IsMember}}<hr><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener" class="calls-menu-danger">End meeting for everyone</a>{{end}} |
| + </div> |
| {{/* Chat, as a sidebar sliding over the picture's right-hand side |
| (the Mac's chat sidebar): closed until asked for, and closing it |
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go| index e8356ac..e8ba0c4 100644 |
| --- a/internal/callstest/browser_test.go |
| +++ b/internal/callstest/browser_test.go |
| @@ -84,6 +84,7 @@ var scenarios = []string{ |
| "layout", |
| "glyphs", |
| "window", |
| + "title", |
| "chrome", |
| "devices", |
| "audiotest", |
diff --git a/internal/callstest/chrome_test.go b/internal/callstest/chrome_test.go| index d54c50b..cb4d93e 100644 |
| --- a/internal/callstest/chrome_test.go |
| +++ b/internal/callstest/chrome_test.go |
| @@ -302,7 +302,9 @@ func TestMeetingsAreListRowsWithOneMenu(t *testing.T) { |
| } |
| // r3 audit C4 / spec S10: Start a call names the call in the starter's |
| -// own time, and without script names the zone it used. |
| +// own time. Without script it is the instance's time, and since |
| +// 2026-10-02 the title no longer names that zone ("… UTC"): the zone is |
| +// kept with the meeting instead (Meeting.TitleZone). |
| func TestStartACallTitlesInTheStartersZone(t *testing.T) { |
| x := newHarness(t, "paul@example.com") |
| c := x.browser(t) |
| @@ -313,9 +315,12 @@ func TestStartACallTitlesInTheStartersZone(t *testing.T) { |
| t.Errorf("with the browser's zone the title is not %q:\n%s", want, rec.Body.String()) |
| } |
| rec = c.post("/meetings/start", nil) |
| - if want := "Call, " + x.now.UTC().Format("2 Jan 15:04") + " UTC"; !strings.Contains(rec.Body.String(), want) { |
| + if want := "Call, " + x.now.UTC().Format("2 Jan 15:04") + "</"; !strings.Contains(rec.Body.String(), want) { |
| t.Errorf("without one the title is not %q", want) |
| } |
| + if strings.Contains(rec.Body.String(), x.now.UTC().Format("2 Jan 15:04")+" UTC") { |
| + t.Error("the title still names the instance zone") |
| + } |
| } |
| // Spec §4's grep gate: one notice vocabulary, rastrillo's callout in a |
| @@ -349,8 +354,10 @@ func TestTemplatesUseCalloutTonesOnly(t *testing.T) { |
| func TestEveryPlainTextErrorIsClassified(t *testing.T) { |
| classified := map[string]int{ |
| // The native app's JSON endpoints (/native/*): a Swift client |
| - // reads the status, and a page is the wrong answer to it. |
| - "native_auth.go": 12, |
| + // reads the status, and a page is the wrong answer to it. The |
| + // thirteenth is /native/request's 410 for an offer that has gone |
| + // (2026-10-02), which callscore reads as ErrOfferExpired. |
| + "native_auth.go": 13, |
| // POST /native/devices: JSON from the native app. |
| "knockpush.go": 7, |
| // methodNotAllowed's 405 (no person navigates with DELETE) and |
diff --git a/internal/callstest/native_applink_browser_test.go b/internal/callstest/native_applink_browser_test.go| new file mode 100644 |
| index 0000000..d5f6cfd |
| --- /dev/null |
| +++ b/internal/callstest/native_applink_browser_test.go |
| @@ -0,0 +1,276 @@ |
| +//go:build browser |
| + |
| +package callstest |
| + |
| +import ( |
| + "bufio" |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "net/http/cookiejar" |
| + "net/url" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/calls/callscore" |
| +) |
| + |
| +// TestNativeBrowserAppLink is "App link doesn't work if you're signed in" |
| +// (a Calls user, 2026-10-02), case by case. The browser is real Chromium |
| +// (native-applink.mjs); the app is callscore, called exactly as the app |
| +// calls it, holding its own session or none. |
| +// |
| +// Before the fix the web's "Join in the app" was |
| +// oficina-calls://join?url=<origin>/m/<sqid>, which names the meeting and |
| +// nothing about who is asking. The app's session is its own: a member's |
| +// app opened it with the member's session whoever was in the browser, and |
| +// an app with none ran a sign-in in the default browser, where a guest — |
| +// who has no account to sign in with — could go no further than the |
| +// sign-in page. Now the button makes an offer in this browser and the |
| +// app's request names it, so the person confirms where they already are. |
| +func TestNativeBrowserAppLink(t *testing.T) { |
| + rig := newBrowserRig(t) |
| + room, token := rig.createMeeting(t) |
| + cookie := func(v string) map[string]string { return map[string]string{"name": rig.cookieName, "value": v} } |
| + |
| + // The app holding a session: what OpenLink answers for the meeting. |
| + appOpens := func(t *testing.T, session, address string) (string, error) { |
| + jar, _ := cookiejar.New(nil) |
| + u, _ := url.Parse(rig.origin) |
| + jar.SetCookies(u, []*http.Cookie{{Name: rig.cookieName, Value: session}}) |
| + return callscore.OpenLink(t.Context(), rig.origin, address, &http.Client{Jar: jar}) |
| + } |
| + // Who the app is, in the meeting, with the session it ended up with. |
| + info := func(t *testing.T, session string) map[string]any { |
| + req, _ := http.NewRequest("GET", rig.origin+"/m/"+room+"/info", nil) |
| + req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: session}) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + var out map[string]any |
| + body, _ := io.ReadAll(res.Body) |
| + if res.StatusCode != 200 || json.Unmarshal(body, &out) != nil { |
| + t.Fatalf("the app's session cannot open the meeting: %d %s", res.StatusCode, body) |
| + } |
| + return out |
| + } |
| + // The app taking up an offer, the browser confirming it. |
| + handOver := func(t *testing.T, b *applinkBrowser, appURL string) map[string]any { |
| + u, err := url.Parse(appURL) |
| + if err != nil || u.Scheme != "oficina-calls" || u.Host != "join" { |
| + t.Fatalf("not an app link: %q", appURL) |
| + } |
| + address, offer := u.Query().Get("url"), u.Query().Get("offer") |
| + if address != rig.origin+"/m/"+room || offer == "" { |
| + t.Fatalf("the app link does not name this meeting and an offer: %q", appURL) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) |
| + defer cancel() |
| + auth, err := callscore.BeginOfferAuthentication(ctx, address, offer) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer auth.Cancel() |
| + // A second app answering the same link is refused: the offer is |
| + // taken once. |
| + if _, err := callscore.BeginOfferAuthentication(ctx, address, offer); !errors.Is(err, callscore.ErrOfferExpired) { |
| + t.Fatalf("an offer was taken twice: %v", err) |
| + } |
| + done := b.do(t, map[string]any{"cmd": "approve", "code": auth.ConfirmationCode()}) |
| + if !strings.Contains(done["text"].(string), "Return to the Calls app") { |
| + t.Fatalf("no completion: %v", done) |
| + } |
| + result, err := auth.Wait() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if result.Room != room || result.Origin != rig.origin || len(result.Cookies) != 1 { |
| + t.Fatalf("wrong destination: %+v", result) |
| + } |
| + return info(t, result.Cookies[0].Value) |
| + } |
| + |
| + t.Run("member on the web, app signed in", func(t *testing.T) { |
| + // The case that always worked: the app's own session opens it, |
| + // with no browser at all. |
| + b := newApplinkBrowser(t, rig.origin) |
| + b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA)}) |
| + if got := b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}); got["appHref"] != "/m/"+room+"/app" { |
| + t.Fatalf("the holding screen's Join in the app: %v", got["appHref"]) |
| + } |
| + _, appSession := rig.signIn(t, memberA) |
| + got, err := appOpens(t, appSession, rig.origin+"/m/"+room) |
| + if err != nil || got != room { |
| + t.Fatalf("OpenLink %q %v", got, err) |
| + } |
| + if who := info(t, appSession); who["name"] != "paul" || who["standing"] != "member" { |
| + t.Fatalf("the app is %v", who) |
| + } |
| + }) |
| + |
| + t.Run("member on the web, app signed out", func(t *testing.T) { |
| + b := newApplinkBrowser(t, rig.origin) |
| + b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA)}) |
| + b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}) |
| + app := b.do(t, map[string]any{"cmd": "click-app"}) |
| + if who := handOver(t, b, app["app"].(string)); who["name"] != "paul" || who["standing"] != "member" { |
| + t.Fatalf("the app is %v", who) |
| + } |
| + }) |
| + |
| + t.Run("member on the web, app signed out, no script", func(t *testing.T) { |
| + // The waiting page refreshes itself with no script and arrives at |
| + // the same confirmation. |
| + b := newApplinkBrowser(t, rig.origin) |
| + b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA), "js": false}) |
| + b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}) |
| + app := b.do(t, map[string]any{"cmd": "click-app"}) |
| + if who := handOver(t, b, app["app"].(string)); who["name"] != "paul" { |
| + t.Fatalf("the app is %v", who) |
| + } |
| + }) |
| + |
| + t.Run("guest signed in on the web", func(t *testing.T) { |
| + // Bea, not on this team, signed in by email and admitted through |
| + // the invitation (a pending join, spec §3.2). The old /m/ link's |
| + // sign-in ran in the app's default browser; anywhere she was not |
| + // signed in it was a sign-in page that sends a non-member no mail. |
| + rig.ctl.SetAllowlist(memberA) |
| + defer rig.ctl.SetAllowlist(memberA, memberB) |
| + b := newApplinkBrowser(t, rig.origin) |
| + b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieB)}) |
| + landed := b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) |
| + if !strings.HasSuffix(landed["url"].(string), "/m/"+room+"/join") { |
| + t.Fatalf("the invitation did not admit her: %v", landed["url"]) |
| + } |
| + app := b.do(t, map[string]any{"cmd": "click-app"}) |
| + if who := handOver(t, b, app["app"].(string)); who["name"] != "bea" || who["standing"] != "guest" { |
| + t.Fatalf("the app is %v", who) |
| + } |
| + // And the app signed in as somebody else on this server is told |
| + // it cannot use its own session for this meeting, rather than |
| + // joining as them or failing at the join. |
| + _, other := rig.signIn(t, memberA) |
| + rig.ctl.SetAllowlist(memberB) // A is now nobody here |
| + if _, err := appOpens(t, other, rig.origin+"/m/"+room); !errors.Is(err, callscore.ErrNoAccess) && !errors.Is(err, callscore.ErrSignedOut) { |
| + t.Fatalf("a session without standing opened the meeting: %v", err) |
| + } |
| + }) |
| + |
| + t.Run("guest from the invitation page", func(t *testing.T) { |
| + // Nobody signed in: the invitation's own page offers the app with |
| + // the invitation itself, and the app's sign-in in a fresh browser |
| + // is the guest name form, then the confirmation. |
| + b := newApplinkBrowser(t, rig.origin) |
| + b.do(t, map[string]any{"cmd": "context", "cookie": nil}) |
| + page := b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) |
| + u, _ := url.Parse(page["appHref"].(string)) |
| + address := u.Query().Get("url") |
| + if u.Scheme != "oficina-calls" || address != rig.origin+"/j/"+token { |
| + t.Fatalf("the invitation page's app link: %v", page["appHref"]) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) |
| + defer cancel() |
| + auth, err := callscore.BeginAuthentication(ctx, address) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer auth.Cancel() |
| + fresh := newApplinkBrowser(t, rig.origin) |
| + fresh.do(t, map[string]any{"cmd": "context", "cookie": nil}) |
| + start := strings.TrimPrefix(auth.BrowserURL(), rig.origin) |
| + fresh.do(t, map[string]any{"cmd": "goto", "path": start}) |
| + fresh.do(t, map[string]any{"cmd": "guest", "name": "Dee"}) |
| + fresh.do(t, map[string]any{"cmd": "approve", "code": auth.ConfirmationCode()}) |
| + result, err := auth.Wait() |
| + if err != nil || result.Room != room { |
| + t.Fatalf("%+v %v", result, err) |
| + } |
| + if who := info(t, result.Cookies[0].Value); who["name"] != "Dee" || who["standing"] != "guest" { |
| + t.Fatalf("the app is %v", who) |
| + } |
| + }) |
| + |
| + t.Run("guest from the invitation page, continued in the browser", func(t *testing.T) { |
| + // Gave a name and went on in the browser, then chose Join in the |
| + // app on the holding screen: the case the /m/ link could not do |
| + // at all outside this one browser. |
| + b := newApplinkBrowser(t, rig.origin) |
| + b.do(t, map[string]any{"cmd": "context", "cookie": nil}) |
| + b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) |
| + b.do(t, map[string]any{"cmd": "guest", "name": "Eve"}) |
| + app := b.do(t, map[string]any{"cmd": "click-app"}) |
| + if who := handOver(t, b, app["app"].(string)); who["name"] != "Eve" || who["standing"] != "guest" { |
| + t.Fatalf("the app is %v", who) |
| + } |
| + }) |
| +} |
| + |
| +// applinkBrowser is one node process running native-applink.mjs. |
| +type applinkBrowser struct { |
| + in io.WriteCloser |
| + out *bufio.Scanner |
| +} |
| + |
| +func newApplinkBrowser(t *testing.T, origin string) *applinkBrowser { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) |
| + cmd := exec.CommandContext(ctx, "node", filepath.Join(repoRootForBrowser(t), "test/browser/native-applink.mjs")) |
| + in, err := cmd.StdinPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out, err := cmd.StdoutPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var stderr bytes.Buffer |
| + cmd.Stderr = &stderr |
| + if err := cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + b := &applinkBrowser{in: in, out: bufio.NewScanner(out)} |
| + b.out.Buffer(make([]byte, 64*1024), 1<<20) |
| + json.NewEncoder(in).Encode(map[string]string{"origin": origin}) |
| + t.Cleanup(func() { |
| + json.NewEncoder(in).Encode(map[string]string{"cmd": "quit"}) |
| + in.Close() |
| + done := make(chan struct{}) |
| + go func() { cmd.Wait(); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(10 * time.Second): |
| + } |
| + cancel() |
| + if t.Failed() && stderr.Len() > 0 { |
| + t.Logf("browser stderr: %s", stderr.String()) |
| + } |
| + }) |
| + return b |
| +} |
| + |
| +func (b *applinkBrowser) do(t *testing.T, c map[string]any) map[string]any { |
| + t.Helper() |
| + if err := json.NewEncoder(b.in).Encode(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !b.out.Scan() { |
| + t.Fatalf("browser gave no answer to %v", c["cmd"]) |
| + } |
| + var got map[string]any |
| + if err := json.Unmarshal(b.out.Bytes(), &got); err != nil { |
| + t.Fatalf("browser answered %q", b.out.Text()) |
| + } |
| + if e, ok := got["error"]; ok { |
| + t.Fatalf("browser, %v: %v", c["cmd"], e) |
| + } |
| + return got |
| +} |
diff --git a/internal/callstest/room_test.go b/internal/callstest/room_test.go| index 180eda6..7ad239f 100644 |
| --- a/internal/callstest/room_test.go |
| +++ b/internal/callstest/room_test.go |
| @@ -4,11 +4,15 @@ import ( |
| "encoding/json" |
| "net/http" |
| "net/url" |
| + "regexp" |
| + "strconv" |
| "strings" |
| "testing" |
| "time" |
| ) |
| +var sqidInPage = regexp.MustCompile(`/m/([23456789a-z]{10})`) |
| + |
| // Renamed from TestRoomPageIsScriptlessHonestAndStandingGated: the |
| // standing gate is not tested here and never was — fix round 1's |
| // Important 1 moved it to |
| @@ -708,13 +712,13 @@ func TestRoomAnnouncesUnreadChatOutsideTheCollapsingPanel(t *testing.T) { |
| // The call window (2026-09-28): the room replaces the page shell with |
| // one rounded window, so what the shell carried has to be somewhere a |
| -// scriptless browser can still reach. The title, the standing line and |
| -// the floor notice are server-rendered in the window's title, outside |
| -// #room; the navigation — Meetings, Settings, Help and Sign out — is in |
| -// the title's menu, a native <details>, so it opens with no script; and |
| -// "End the meeting", which with script is in the bar's More menu inside |
| -// #room, is in the <noscript> block beside the Share form, as a link to |
| -// its own confirm page. |
| +// scriptless browser can still reach. Since 2026-10-02 there is no |
| +// title pill and no ☰ menu: the header is the title, and — inside |
| +// <noscript>, so it is the scriptless page's and nobody else's — the |
| +// standing line, the floor notice and the navigation (Meetings, |
| +// Settings, Help, Sign out). With script the same navigation is in the |
| +// bar's More menu, inside #room, with End meeting for everyone last; and |
| +// with no script that is in the scriptless block beside the Share form. |
| func TestRoomIsOneWindowWhoseNavigationWorksWithoutScript(t *testing.T) { |
| x, sqid, _ := memberWithMeeting(t) |
| c := x.browser(t) |
| @@ -729,40 +733,62 @@ func TestRoomIsOneWindowWhoseNavigationWorksWithoutScript(t *testing.T) { |
| if strings.Contains(body, "rst-shell-topbar") { |
| t.Fatal("the room still renders the page shell's header") |
| } |
| - title := strings.Index(body, `<h1 id="call-title">Board meeting</h1>`) |
| - standing := strings.Index(body, "You’re in this call as paul.") |
| - if title < win || title > room || standing < win || standing > room { |
| - t.Fatalf("the title and the standing line must be server-rendered in the window, outside #room: title %d standing %d room %d", title, standing, room) |
| + for _, gone := range []string{`id="call-nav"`, `id="call-state"`, "calls-nav-menu"} { |
| + if strings.Contains(body, gone) { |
| + t.Fatalf("the title pill's %s is still there", gone) |
| + } |
| + } |
| + header := body[strings.Index(body, `<header id="call-top"`):strings.Index(body, `</header>`)] |
| + if !strings.Contains(header, `<h1 id="call-title">Board meeting</h1>`) { |
| + t.Fatalf("the header must carry the title:\n%s", header) |
| } |
| - nav := strings.Index(body, `<details id="call-nav"`) |
| - if nav < win || nav > room { |
| - t.Fatalf("the title's menu must be outside #room, so it works with no script: %d", nav) |
| + ns := strings.Index(header, "<noscript>") |
| + if ns < 0 { |
| + t.Fatalf("the header has no scriptless part:\n%s", header) |
| } |
| - menu := body[nav:room] |
| - for _, want := range []string{`href="/">Meetings</a>`, `href="/settings">Settings</a>`, `href="/help/open-source"`, `<form method="post" action="/signout"><button type="submit">Sign out</button></form>`} { |
| - if !strings.Contains(menu, want) { |
| - t.Fatalf("the title's menu lacks %q:\n%s", want, menu) |
| + for _, want := range []string{"You’re in this call as paul.", "Calls outside your network may not connect yet.", `href="/">Meetings</a>`, `href="/settings">Settings</a>`, `href="/help/open-source"`, `<form method="post" action="/signout"><button type="submit">Sign out</button></form>`} { |
| + if !strings.Contains(header[ns:], want) { |
| + t.Fatalf("with no script the header lacks %q:\n%s", want, header[ns:]) |
| + } |
| + if strings.Contains(header[:ns], want) { |
| + t.Fatalf("with script the header is only the title, but it has %q", want) |
| } |
| } |
| - ns := strings.Index(body, `<noscript><div class="calls-scriptless">`) |
| - nsEnd := ns + strings.Index(body[max(ns, 0):], "</noscript>") |
| - if ns < win || nsEnd < ns || nsEnd > room { |
| - t.Fatalf("the scriptless block belongs in the window, before #room: %d..%d, room %d", ns, nsEnd, room) |
| + sns := strings.Index(body, `<noscript><div class="calls-scriptless">`) |
| + nsEnd := sns + strings.Index(body[max(sns, 0):], "</noscript>") |
| + if sns < win || nsEnd < sns || nsEnd > room { |
| + t.Fatalf("the scriptless block belongs in the window, before #room: %d..%d, room %d", sns, nsEnd, room) |
| } |
| - if !strings.Contains(body[ns:nsEnd], `href="/meetings/`+sqid+`/end"`) { |
| - t.Fatalf("with no script, End the meeting must still be reachable:\n%s", body[ns:nsEnd]) |
| + if !strings.Contains(body[sns:nsEnd], `href="/meetings/`+sqid+`/end"`) { |
| + t.Fatalf("with no script, End the meeting must still be reachable:\n%s", body[sns:nsEnd]) |
| } |
| - // With script, End meeting for everyone is in the bar's More menu: |
| - // still a link to its own confirm page, never a button that ends it. |
| + // With script it is all in the bar's More menu: the standing line |
| + // first, then the account menu's items, End meeting for everyone |
| + // last — still a link to its own confirm page, never a button that |
| + // ends it. |
| more := strings.Index(body, `<div id="call-more"`) |
| - if more < room || !strings.Contains(body[more:], `<a href="/meetings/`+sqid+`/end" target="_blank"`) { |
| - t.Fatalf("the bar's More menu must link End meeting for everyone to its confirm page") |
| + if more < room { |
| + t.Fatal("the bar's More menu must be inside #room") |
| + } |
| + menu := body[more : more+strings.Index(body[more:], "</div>")] |
| + order := []string{`<p class="calls-menu-standing">You’re in this call as paul.`, `href="/">Meetings</a>`, `href="/settings">Settings</a>`, `href="/help/open-source"`, `action="/signout"`, `<a href="/meetings/` + sqid + `/end" target="_blank"`} |
| + at := 0 |
| + for _, want := range order { |
| + i := strings.Index(menu[at:], want) |
| + if i < 0 { |
| + t.Fatalf("the More menu lacks %q after position %d:\n%s", want, at, menu) |
| + } |
| + at += i |
| + } |
| + if !strings.Contains(body, `id="call-more-toggle"`) { |
| + t.Fatal("the bar has no More button") |
| } |
| } |
| // A guest's room has no member actions anywhere: no Meetings list, no |
| -// Settings, no Share link and no way to end the meeting, in the menu, |
| -// the bar or the scriptless block. |
| +// Settings, no Share link and no way to end the meeting, in the More |
| +// menu, the bar or the scriptless block. More itself is there: it is |
| +// where a guest's Help and Sign out are. |
| func TestGuestRoomWindowCarriesNoMemberActions(t *testing.T) { |
| x, sqid, token := memberWithMeeting(t) |
| guest := x.browser(t) |
| @@ -773,44 +799,49 @@ func TestGuestRoomWindowCarriesNoMemberActions(t *testing.T) { |
| if rec.Code != http.StatusOK || !strings.Contains(body, ", a guest.") { |
| t.Fatalf("guest room: %d\n%s", rec.Code, body) |
| } |
| - for _, not := range []string{`href="/">Meetings</a>`, `href="/settings"`, `/end"`, `action="/m/` + sqid + `/share"`, `id="call-more"`} { |
| + for _, not := range []string{`href="/">Meetings</a>`, `href="/settings"`, `/end"`, `action="/m/` + sqid + `/share"`} { |
| if strings.Contains(body, not) { |
| t.Fatalf("a guest's room carries %q", not) |
| } |
| } |
| - if !strings.Contains(body, `action="/signout"`) || !strings.Contains(body, `href="/help/open-source"`) { |
| - t.Fatal("a guest keeps Help and Sign out in the title's menu") |
| + more := strings.Index(body, `<div id="call-more"`) |
| + if more < 0 || !strings.Contains(body[more:], `action="/signout"`) || !strings.Contains(body[more:], `href="/help/open-source"`) { |
| + t.Fatal("a guest keeps Help and Sign out in the More menu") |
| } |
| } |
| -// The title pill (2026-09-29) is the title and a few words, as the |
| -// Mac's is. The standing line is the first line of the title's menu, |
| -// and the relay notice is one of the notices, shown once a session — |
| -// both script-driven places, so with no script the pill carries both |
| -// in full, inside <noscript>. |
| -func TestRoomTitlePillMovesStandingAndRelayNoticeOut(t *testing.T) { |
| - x, sqid, _ := memberWithMeeting(t) |
| +// The title (2026-10-02): a title someone chose is the window's title, |
| +// centred in the strip at the top (calls-top-named); a title Calls |
| +// wrote is the page's heading and its <title>, and nothing on screen. |
| +// The apps read the same fact from /info's "named". |
| +func TestRoomShowsAChosenTitleAndLeavesAnAutomaticOneOff(t *testing.T) { |
| + x, named, _ := memberWithMeeting(t) |
| c := x.browser(t) |
| c.signIn("paul@example.com") |
| - body := c.get("/m/" + sqid).Body.String() |
| - |
| - nav := strings.Index(body, `<nav class="calls-nav-menu"`) |
| - standing := strings.Index(body, `<p class="calls-nav-standing">You’re in this call as paul.`) |
| - meetings := strings.Index(body, `href="/">Meetings</a>`) |
| - if nav < 0 || standing < nav || meetings < standing { |
| - t.Fatalf("the standing line must open the title's menu, above Meetings: nav %d standing %d meetings %d", nav, standing, meetings) |
| - } |
| - top := body[strings.Index(body, `<div class="calls-top-text">`):strings.Index(body, `</header>`)] |
| - ns := strings.Index(top, "<noscript>") |
| - if ns < 0 || !strings.Contains(top[ns:], "You’re in this call as paul.") || !strings.Contains(top[ns:], "Calls outside your network may not connect yet.") { |
| - t.Fatalf("with no script the pill must still say the standing and the relay notice:\n%s", top) |
| - } |
| - if strings.Contains(top[:ns], "You’re in this call as paul.") || strings.Contains(top[:ns], "Calls outside your network may not connect yet.") { |
| - t.Fatalf("with script the pill is the title and its status only:\n%s", top[:ns]) |
| + rec := c.post("/meetings/start", nil) |
| + auto := sqidInPage.FindStringSubmatch(rec.Body.String()) |
| + if auto == nil { |
| + t.Fatalf("no meeting on the started call's page:\n%s", rec.Body.String()) |
| + } |
| + for _, tc := range []struct { |
| + sqid string |
| + named bool |
| + }{{named, true}, {auto[1], false}} { |
| + body := c.get("/m/" + tc.sqid).Body.String() |
| + start := strings.Index(body, `<header id="call-top"`) |
| + header := body[start : start+strings.Index(body[start:], ">")+1] |
| + if got := strings.Contains(header, "calls-top-named"); got != tc.named { |
| + t.Errorf("%s: header %q, named %v", tc.sqid, header, tc.named) |
| + } |
| + if !strings.Contains(body, `<h1 id="call-title">`) || !strings.Contains(body, "<title>") { |
| + t.Errorf("%s: the title is still the heading and the <title>", tc.sqid) |
| + } |
| + info := c.get("/m/" + tc.sqid + "/info").Body.String() |
| + if want := `"named":` + strconv.FormatBool(tc.named); !strings.Contains(info, want) { |
| + t.Errorf("%s: info %s lacks %s", tc.sqid, info, want) |
| + } |
| } |
| - room := strings.Index(body, `<div id="room"`) |
| - floor := strings.Index(body, `<div id="floor-notice" class="calls-floor-notice" hidden>`) |
| - if floor < room || !strings.Contains(body[floor:], `id="floor-dismiss"`) { |
| - t.Fatalf("the relay notice belongs in #room's notices, hidden until script shows it, with a Dismiss: %d", floor) |
| + if body := c.get("/m/" + auto[1]).Body.String(); strings.Contains(body, " UTC</") { |
| + t.Error("an automatic title names its zone") |
| } |
| } |
diff --git a/native/README.md b/native/README.md| index 2dc0fb9..0405e19 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -44,8 +44,14 @@ their own server. |
| **Links.** A meeting link — `oficina-calls://join?url=…` from the browser, or |
| one pasted into the signed-in home's field — never changes the server setting. |
| -On the server you are signed in to, it opens with the session the app holds, as |
| -before. Otherwise a link arriving through `oficina-calls://` is offered, not |
| +On the server you are signed in to, it opens with the session the app holds if |
| +that session may join the meeting (`OpenLink` asks the meeting's `/info`); if it |
| +may not — the app is signed in as somebody else — the home says so and offers a |
| +sign-in for that meeting only. A link from a signed-in browser's **Join in the |
| +app** also carries an `offer` (2026-10-02): the app takes it up at once, opens |
| +no browser, and the page the person came from asks them to confirm the app's |
| +code, handing over their own session — member or guest — for that meeting. |
| +Otherwise a link arriving through `oficina-calls://` is offered, not |
| started: the home names the server the meeting is on with a **Sign in** button |
| (and **Not now**), so a browser never opens for a server the person has not seen |
| named. One pasted into the field is the person's own act, so **Open link** goes |
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift| index 192da92..700b9a0 100644 |
| --- a/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| @@ -39,10 +39,17 @@ import SwiftUI |
| try await shot("devices", .focus, devices: true) |
| try await shot("chat", .focus, chat: true) |
| try await shot("notices-hidden", .notices, shown: false) |
| + // 2026-10-02: no title pill. A named meeting's title is centred in |
| + // the title bar's strip and fades with the controls; an automatic |
| + // one ("Call, 24 Sep 10:21", the focus fixture) is not shown. |
| + try await shot("named-controls", .named) |
| + try await shot("named-hidden", .named, shown: false) |
| + try await shot("named-white", .named, backdrop: .white) |
| + try await shot("named-busy", .named, backdrop: .busy) |
| try await shot("classic-focus", .focus, style: .classic) |
| try await shot("classic-grid", .grid, grid: true, style: .classic) |
| - // The self view in the corner the title pill shares: it sits below |
| - // the pill while the controls show. |
| + // The self view in the top-left corner: below the title bar's strip |
| + // (there is no pill to keep clear of since 2026-10-02). |
| let corner = UserDefaults.standard.string(forKey: PiPCorner.key) |
| UserDefaults.standard.set(PiPCorner.topLeading.rawValue, forKey: PiPCorner.key) |
| try await shot("pip-top-leading-controls", .focus) |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 46b4418..fe840b0 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -33,6 +33,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// This viewer's layout, pin and order of people (0.4.0). |
| public let arrangement = CallArrangement() |
| @Published public private(set) var title = "Calls" |
| + /// Whether `title` is one someone chose, shown over the call, rather |
| + /// than one Calls wrote, which is not (CallTitle). |
| + @Published public private(set) var named = false |
| @Published public private(set) var standing = "" |
| @Published public private(set) var status = "Ready to join" |
| @Published public private(set) var remoteTracks: [String: RTCVideoTrack] = [:] |
| @@ -775,6 +778,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| case "info": |
| if let info = event.info { |
| title = info.title |
| + named = info.named ?? false |
| standing = info.standing |
| floor = info.floor |
| } |
| @@ -1222,9 +1226,10 @@ extension Call { |
| @_spi(Fixtures) public func showFixture( |
| title: String, standing: String = "member", status: String = "In the meeting", |
| state json: String, screens: [String] = [], sharingScreen: Bool = false, |
| - muted: Bool = false, floor: Bool = false |
| + muted: Bool = false, floor: Bool = false, named: Bool = false |
| ) { |
| self.title = title |
| + self.named = named |
| self.standing = standing |
| self.state = try? JSONDecoder().decode(RoomState.self, from: Data(json.utf8)) |
| self.status = status |
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift| index bb3ea17..024b197 100644 |
| --- a/native/apple/Sources/CallsKit/CallChrome.swift |
| +++ b/native/apple/Sources/CallsKit/CallChrome.swift |
| @@ -329,6 +329,10 @@ public enum PiPLayout { |
| /// Something the call needs to say, over the picture. |
| public struct CallNotice: Identifiable, Equatable, Sendable { |
| public enum Kind: Equatable, Sendable { |
| + /// The call is being reached: before the first roster, and between a |
| + /// fresh stream and admission. The title pill said "Connecting" until |
| + /// 2026-10-02; with the pill gone this notice is where it is said. |
| + case connecting |
| /// The connection dropped and is coming back. |
| case reconnecting |
| /// You are waiting for a member to let you in. |
| @@ -372,9 +376,21 @@ public struct CallNotice: Identifiable, Equatable, Sendable { |
| } |
| public enum CallNotices { |
| + /// The call's own line when there is nothing to say about it: in the |
| + /// meeting, not yet joining, or left. Every other status is a notice. |
| + public static let quietStatuses: Set<String> = [ |
| + "In the meeting", "Ready to join", "You left the meeting", |
| + ] |
| + |
| /// What to say over the call, most urgent first. `status` is the call's |
| - /// own line ("In the meeting"), which is only a notice when it reports |
| - /// a problem. |
| + /// own line ("In the meeting"). |
| + /// |
| + /// Since 2026-10-02 there is no title pill showing `status` beside the |
| + /// title, so every status that is not one of `quietStatuses` is a |
| + /// notice here: Connecting…, Waiting to be let in, the room full, |
| + /// Reconnecting…, the call over, and anything that went wrong ("Could |
| + /// not…", "A participant’s media could not connect.", "Keep the |
| + /// message within 2 KB.") — not only the ones that began "Could not". |
| public static func notices( |
| phase: String?, status: String, sharingScreen: Bool, screenNote: String, floor: Bool, |
| screenRecovery: String = "" |
| @@ -386,7 +402,11 @@ public enum CallNotices { |
| case "full": out.append(CallNotice(kind: .full, text: status)) |
| case "ended": out.append(CallNotice(kind: .ended, text: status)) |
| default: |
| - if status.hasPrefix("Could not") { out.append(CallNotice(kind: .problem, text: status)) } |
| + if status == "Connecting…" { |
| + out.append(CallNotice(kind: .connecting, text: status)) |
| + } else if !status.isEmpty, !quietStatuses.contains(status) { |
| + out.append(CallNotice(kind: .problem, text: status)) |
| + } |
| } |
| if sharingScreen { |
| out.append(CallNotice(kind: .sharing, text: "You are sharing your screen")) |
| @@ -405,6 +425,27 @@ public enum CallNotices { |
| } |
| } |
| +/// The call's title over the picture (2026-10-02, Paul approved). There |
| +/// is no pill: a title someone chose is shown the way a window shows its |
| +/// title — plain, centred, fading with the controls — and a title Calls |
| +/// wrote ("Call, 2 Oct 15:40") is not shown at all. The window's own |
| +/// title, which the Window menu, Mission Control and VoiceOver read, is |
| +/// the meeting's title either way. |
| +public enum CallTitle { |
| + /// What to show over the call, or nil for nothing. |
| + public static func shown(title: String, named: Bool) -> String? { |
| + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) |
| + return named && !trimmed.isEmpty ? trimmed : nil |
| + } |
| + |
| + /// The window's title: the meeting's, named or not, and the app's name |
| + /// before a meeting has said what it is called. |
| + public static func window(title: String) -> String { |
| + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) |
| + return trimmed.isEmpty ? "Calls" : trimmed |
| + } |
| +} |
| + |
| /// A notice's own button (`CallNotice.action`): a compact light capsule |
| /// that reads on the dark glass, reachable from the keyboard, where |
| /// Return as well as Space presses it. |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| index f09378d..0f954e7 100644 |
| --- a/native/apple/Sources/CallsKit/CallFixtures.swift |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -19,8 +19,12 @@ import SwiftUI |
| /// Two people who have just joined (spec §15.24): one still connecting |
| /// and unable to hear you yet, the other's browser blocking sound. |
| case connecting |
| + /// The focus call in a meeting someone named: its title is shown, |
| + /// centred where a window's title would be (CallTitle). |
| + case named |
| public static let title = "Call, 24 Sep 10:21" |
| + public static let namedTitle = "Board meeting" |
| @MainActor public func apply(to call: Call) { |
| let me = #"{"peer":"me","name":"Paul Campbell","kind":"member"}"# |
| @@ -49,6 +53,8 @@ import SwiftUI |
| let bea = #"{"peer":"bea","name":"Bea Ruiz","kind":"guest","media":{"muted":false,"cameraOff":true,"audioBlocked":true,"hearing":[]}}"# |
| call.showFixture(title: Self.title, state: roster("admitted", [me, ana, sam, bea])) |
| call.showFixtureConnecting(["sam", "bea"]) |
| + case .named: |
| + call.showFixture(title: Self.namedTitle, state: roster("admitted", [me, ana]), named: true) |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift| index acf5a40..e7a63c6 100644 |
| --- a/native/apple/Sources/CallsKit/CallsLobby.swift |
| +++ b/native/apple/Sources/CallsKit/CallsLobby.swift |
| @@ -37,13 +37,21 @@ import SwiftUI |
| VStack(alignment: .leading, spacing: 10) { |
| Text(model.confirmationCode).font(.title.monospaced().bold()) |
| .textSelection(.enabled) |
| - Text("Check that your browser shows this same code before confirming.") |
| - .font(.callout).foregroundStyle(.secondary) |
| + Text( |
| + model.handingOff |
| + ? "Go back to your browser — the page you came from is asking you to confirm. Check it shows this same code." |
| + : "Check that your browser shows this same code before confirming." |
| + ) |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| }.frame(maxWidth: .infinity, alignment: .leading).padding(16) |
| .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) |
| } |
| if let url = model.signin { |
| - Button("Open browser again", systemImage: "arrow.up.forward.app") { |
| + Button( |
| + model.handingOff ? "Confirm in another browser" : "Open browser again", |
| + systemImage: "arrow.up.forward.app" |
| + ) { |
| Task { _ = await model.openBrowser(url) } |
| }.buttonStyle(.borderedProminent) |
| } |
| @@ -158,11 +166,7 @@ import SwiftUI |
| @ViewBuilder private var pendingLink: some View { |
| Label("A meeting on \(model.pendingHost)", systemImage: "video") |
| .font(.headline).textSelection(.enabled) |
| - Text( |
| - model.pendingOnServer |
| - ? "Sign in to join it." |
| - : "This meeting is on another Calls server. Sign in there to join it — just for this meeting. Your own server stays \(model.serverHost)." |
| - ) |
| + Text(pendingExplanation) |
| .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) |
| Button(action: model.signIn) { |
| Label("Sign in", systemImage: "person.crop.circle").frame(maxWidth: .infinity) |
| @@ -172,6 +176,17 @@ import SwiftUI |
| Button("Not now", action: model.dismissPendingLink).buttonStyle(.bordered) |
| } |
| + /// Why a link needs a sign-in, in words that say what will happen. |
| + private var pendingExplanation: String { |
| + if model.pendingNoAccess { |
| + return |
| + "You’re signed in to \(model.pendingHost) in this app as someone who can’t join this meeting. Sign in for this meeting only — the app stays signed in as you are now." |
| + } |
| + if model.pendingOnServer { return "Sign in to join it." } |
| + return |
| + "This link is for a meeting on another Calls server, \(model.pendingHost). Sign in to it to join — just for this meeting. Your own server stays \(model.serverHost)." |
| + } |
| + |
| /// The signed-in home's address field: how a link someone sent is opened |
| /// without signing in again, so it stays second to the buttons above it. |
| @ViewBuilder private var linkField: some View { |
| @@ -223,6 +238,9 @@ import SwiftUI |
| private var subheading: LocalizedStringKey { |
| if model.restoring { return "Checking that you’re still signed in." } |
| if model.authenticating { |
| + if model.handingOff { |
| + return "Confirm in your browser to join this meeting in the app as you. We’ll continue here automatically." |
| + } |
| return |
| "Continue in your browser, then confirm that the code matches. We’ll continue here automatically." |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift| index 8485039..fbd7441 100644 |
| --- a/native/apple/Sources/CallsKit/CallsModel.swift |
| +++ b/native/apple/Sources/CallsKit/CallsModel.swift |
| @@ -18,6 +18,19 @@ import Foundation |
| /// A meeting link waiting on a sign-in: one that arrived through |
| /// `oficina-calls://join`, on another server or with nobody signed in. |
| @Published public var pendingLink = "" |
| + /// The pending link is on the instance this app is signed in to, but |
| + /// the sign-in is somebody who cannot join that meeting — a guest |
| + /// signed in on the web, say, with the app signed in as a member of |
| + /// another team. Signing in for it is a visit: the app's own sign-in |
| + /// stays. |
| + @Published public var pendingNoAccess = false |
| + /// The browser handoff under way was started by the web's "Join in the |
| + /// app": the person confirms in the browser they came from, which is |
| + /// already showing the code, rather than in one this app opens. |
| + @Published public var handingOff = false |
| + /// The offer from the link that launched the app while the kept |
| + /// sign-in was still being checked, for when the check is done. |
| + private var pendingOffer: String? |
| /// Signed in for one meeting only — as a guest, or on a server that is |
| /// not this app's. Such a session is never kept, and leaving the |
| /// meeting drops it and comes back to the server's own home. |
| @@ -26,7 +39,7 @@ import Foundation |
| @Published public var signin: URL? |
| @Published public var authenticating = false |
| @Published public var confirmationCode = "" |
| - private var authentication: BridgeAuthentication? |
| + private var authentication: (any Handoff)? |
| private var authEpoch = UUID() |
| private var callEpoch = UUID() |
| @Published public var signedIn = false |
| @@ -114,26 +127,53 @@ import Foundation |
| error = "This meeting link is not valid." |
| return |
| } |
| + // The web's "Join in the app" (2026-10-02) adds the offer its |
| + // signed-in browser made: the way to this person's own standing in |
| + // that meeting, confirmed in that browser. An offer that is not the |
| + // shape the server makes is ignored, and the link is an ordinary one. |
| + let offer = components.queryItems?.first(where: { $0.name == "offer" })?.value |
| + .flatMap { Self.validOffer($0) ? $0 : nil } |
| + let address = meeting.absoluteString |
| + pendingNoAccess = false |
| // The link launched the app, and the kept sign-in is still being |
| // checked. check() opens it once the answer is in. |
| if restoring { |
| - pendingLink = meeting.absoluteString |
| + pendingLink = address |
| + pendingOffer = offer |
| openAfterRestore = true |
| return |
| } |
| - // Already signed in to that instance: the session in hand is the |
| - // person's own, so the link opens with it and no browser is involved. |
| - if signedIn, Self.instance(of: meeting.absoluteString) == origin { |
| + // Already signed in to that instance: the session in hand is tried |
| + // first, so a member opening their own meeting needs no browser at |
| + // all. open() falls back to the offer, or a sign-in, if it cannot. |
| + if signedIn, Self.instance(of: address) == origin { |
| + pendingLink = "" |
| + open(address, offer: offer) |
| + return |
| + } |
| + cancelSignIn() |
| + error = "" |
| + // From the web: the person is signed in in the browser they came |
| + // from, and confirming there is the whole of it. Started rather than |
| + // offered — they asked for this meeting in the app a moment ago — and |
| + // a visit unless it is on this app's own server. |
| + if let offer { |
| pendingLink = "" |
| - open(meeting.absoluteString) |
| + begin(address, visit: Self.instance(of: address) != server, retry: "", offer: offer) |
| return |
| } |
| // Another server, or nobody signed in: that is a sign-in, offered |
| // rather than started, and whatever session is in hand stays until a |
| // new one replaces it. A link never changes the server setting. |
| - cancelSignIn() |
| - error = "" |
| - pendingLink = meeting.absoluteString |
| + pendingLink = address |
| + } |
| + |
| + /// An offer is 32 random bytes, base64url without padding. |
| + nonisolated static func validOffer(_ offer: String) -> Bool { |
| + offer.count == 43 |
| + && offer.unicodeScalars.allSatisfy { |
| + CharacterSet.alphanumerics.contains($0) && $0.isASCII || $0 == "-" || $0 == "_" |
| + } |
| } |
| /// The signed-out home's one button: sign in to this app's server, or — |
| @@ -144,12 +184,16 @@ import Foundation |
| begin(server, visit: false, retry: "Choose Sign in to try again.") |
| return |
| } |
| - begin(link, visit: Self.instance(of: link) != server, retry: "Choose Sign in to try again.") |
| + begin( |
| + link, visit: pendingNoAccess || Self.instance(of: link) != server, |
| + retry: "Choose Sign in to try again.") |
| } |
| /// Puts the waiting link down without signing in. |
| public func dismissPendingLink() { |
| pendingLink = "" |
| + pendingNoAccess = false |
| + pendingOffer = nil |
| openAfterRestore = false |
| } |
| @@ -185,32 +229,37 @@ import Foundation |
| /// The browser handoff for an address: the server itself, or a meeting |
| /// link on any server. A visit's session is never kept; `retry` is |
| /// appended to a failure so the error points at the way to try again. |
| - private func begin(_ address: String, visit: Bool, retry: String) { |
| + /// |
| + /// With an `offer` (the web's "Join in the app") the browser the person |
| + /// came from confirms it — it is already waiting for this request — so |
| + /// no browser is opened here, and once the session is in hand the |
| + /// meeting is joined, which is what they asked for. |
| + private func begin(_ address: String, visit: Bool, retry: String, offer: String? = nil) { |
| cancelSignIn() |
| sessionEpoch = UUID() |
| restoring = false |
| authenticating = true |
| + handingOff = offer != nil |
| + pendingOffer = nil |
| error = "" |
| let epoch = authEpoch |
| + let remote = self.remote |
| Task { |
| do { |
| - let auth = try await Task.detached { |
| - var error: NSError? |
| - guard let auth = BridgeBeginAuthentication(address, &error) else { |
| - throw error ?? NSError(domain: "Calls", code: 1) |
| - } |
| - return auth |
| - }.value |
| + let auth = try await Task.detached { try remote.begin(address, offer) }.value |
| guard epoch == authEpoch else { |
| auth.cancel() |
| return |
| } |
| authentication = auth |
| - guard let url = URL(string: auth.browserURL()), await openBrowser(url) else { |
| - auth.cancel() |
| - throw NSError( |
| - domain: "Calls", code: 2, |
| - userInfo: [NSLocalizedDescriptionKey: "Could not open your browser."]) |
| + let url = URL(string: auth.browserURL()) |
| + if offer == nil { |
| + guard let url, await openBrowser(url) else { |
| + auth.cancel() |
| + throw NSError( |
| + domain: "Calls", code: 2, |
| + userInfo: [NSLocalizedDescriptionKey: "Could not open your browser."]) |
| + } |
| } |
| guard epoch == authEpoch else { |
| auth.cancel() |
| @@ -218,14 +267,10 @@ import Foundation |
| } |
| confirmationCode = auth.confirmationCode() |
| signin = url |
| - let result = try await Task.detached { |
| - var error: NSError? |
| - let result = auth.wait(&error) |
| - if let error { throw error } |
| - return result |
| - }.value |
| + let result = try await Task.detached { try auth.wait() }.value |
| guard epoch == authEpoch else { return } |
| try adopt(result, visit: visit) |
| + if offer != nil, !room.isEmpty { join() } |
| #if os(macOS) |
| NSApplication.shared.activate(ignoringOtherApps: true) |
| #endif |
| @@ -234,6 +279,8 @@ import Foundation |
| self.error = [error.localizedDescription, retry].filter { !$0.isEmpty }.joined( |
| separator: " ") |
| cancelSignIn() |
| + // The offer has gone: the meeting is still there to sign in to. |
| + if offer != nil { pendingLink = address } |
| } |
| } |
| } |
| @@ -256,7 +303,9 @@ import Foundation |
| self.visiting = visiting |
| address = "" |
| pendingLink = "" |
| + pendingNoAccess = false |
| authenticating = false |
| + handingOff = false |
| signin = nil |
| authentication = nil |
| if !visiting { keep(StoredSession(origin: origin, cookies: cookies)) } |
| @@ -275,6 +324,7 @@ import Foundation |
| authentication?.cancel() |
| authentication = nil |
| authenticating = false |
| + handingOff = false |
| confirmationCode = "" |
| signin = nil |
| } |
| @@ -308,7 +358,9 @@ import Foundation |
| do { |
| let sqid = try await Task.detached { |
| var error: NSError? |
| - let sqid = BridgeStartMeeting(origin, cookies, &error) |
| + // The device's zone, so the title the server writes is the |
| + // time here ("Call, 2 Oct 15:40"). |
| + let sqid = BridgeStartMeeting(origin, cookies, TimeZone.current.identifier, &error) |
| if let error { throw error } |
| return sqid |
| }.value |
| @@ -343,6 +395,7 @@ import Foundation |
| guard let kept else { |
| restoring = false |
| openAfterRestore = false |
| + continueOffer() |
| return |
| } |
| // Kept for a server that is no longer this app's — the setting was |
| @@ -356,6 +409,7 @@ import Foundation |
| store.clear() |
| remote.signOut(kept.origin, kept.cookies) |
| } |
| + continueOffer() |
| return |
| } |
| origin = kept.origin |
| @@ -387,21 +441,28 @@ import Foundation |
| canStart = true |
| error = "" |
| if openAfterRestore, Self.instance(of: pendingLink) == origin { |
| - let link = pendingLink |
| + let link = pendingLink, offer = pendingOffer |
| pendingLink = "" |
| - open(link) |
| + pendingOffer = nil |
| + open(link, offer: offer) |
| + } else if openAfterRestore { |
| + continueOffer() |
| } |
| case .success(SessionRemote.signedOut): |
| // The instance says the session has gone: thirty days passed, or |
| // it was signed out elsewhere. Forget it; signing in again is the |
| // home's one button. |
| let host = self.host |
| + let offered = openAfterRestore |
| forget() |
| error = "Your sign-in to \(host) has ended. Choose Sign in to continue." |
| + if offered { continueOffer() } |
| case .success: |
| // Live, but no longer a member's: only a member's session is |
| // kept, so this one is not either. |
| + let offered = openAfterRestore |
| forget() |
| + if offered { continueOffer() } |
| case .failure(let failure): |
| // Not an answer about the session. Keep it and use it; every |
| // request it makes is gated on the server regardless. |
| @@ -413,15 +474,29 @@ import Foundation |
| // The link that launched the app is still tried with it: open() |
| // falls back to a browser sign-in if the server says it has ended. |
| if openAfterRestore, Self.instance(of: pendingLink) == origin { |
| - let link = pendingLink |
| + let link = pendingLink, offer = pendingOffer |
| pendingLink = "" |
| - open(link) |
| + pendingOffer = nil |
| + open(link, offer: offer) |
| + } else if openAfterRestore { |
| + continueOffer() |
| } |
| } |
| openAfterRestore = false |
| } |
| } |
| + /// A link from the web's "Join in the app" launched the app, and there |
| + /// is no sign-in it can use: the offer is taken up now, as it would |
| + /// have been had the app already been running. |
| + private func continueOffer() { |
| + guard let offer = pendingOffer, !pendingLink.isEmpty else { return } |
| + let link = pendingLink |
| + pendingOffer = nil |
| + pendingLink = "" |
| + begin(link, visit: Self.instance(of: link) != server, retry: "", offer: offer) |
| + } |
| + |
| /// Keeps a fresh sign-in if it is a member's. Only a member's session |
| /// is worth waking up holding: a guest's, or an invited address's, is |
| /// good for the meeting it came in through and nothing else, and is |
| @@ -526,6 +601,7 @@ import Foundation |
| /// Everything a sign-in left behind, in memory and — unless `keeping`, |
| /// for a visit whose session was never kept — in the Keychain. |
| private func forget(keeping: Bool = false) { |
| + openAfterRestore = false |
| sessionEpoch = UUID() |
| callEpoch = UUID() |
| cancelSignIn() |
| @@ -551,7 +627,7 @@ import Foundation |
| /// A session the instance no longer accepts is forgotten, and the |
| /// link goes through the browser as it would have with no session at |
| /// all. |
| - private func open(_ address: String) { |
| + private func open(_ address: String, offer: String? = nil) { |
| guard !opening, !starting, !joining else { return } |
| opening = true |
| error = "" |
| @@ -566,7 +642,23 @@ import Foundation |
| if link.signedOut == true { |
| let visit = visiting |
| forget(keeping: visit) |
| - begin(address, visit: visit || Self.instance(of: address) != server, retry: "") |
| + begin( |
| + address, visit: visit || Self.instance(of: address) != server, retry: "", |
| + offer: offer) |
| + return |
| + } |
| + // Signed in, as somebody who cannot join that meeting (2026-10-02). |
| + // From the web's "Join in the app", the person in that browser is |
| + // handed over for it; otherwise the person is asked whether to |
| + // sign in for it. Either way it is a visit: the app's own sign-in |
| + // is still the one it wakes up with. |
| + if link.noAccess == true { |
| + if let offer { |
| + begin(address, visit: true, retry: "", offer: offer) |
| + } else { |
| + pendingLink = address |
| + pendingNoAccess = true |
| + } |
| return |
| } |
| self.address = "" |
| @@ -583,6 +675,7 @@ import Foundation |
| private struct OpenedLink: Decodable { |
| let room: String |
| let signedOut: Bool? |
| + let noAccess: Bool? |
| } |
| /// The instance an address is on, written the way the server's origin |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index 9d58731..c92b664 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -21,9 +21,6 @@ |
| @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue |
| @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue |
| @State var pipDrag: CGSize = .zero |
| - /// The title pill's height, so a self view in the top-left corner |
| - /// sits below it rather than under it. |
| - @State var titleHeight: CGFloat = 0 |
| @State var menuTracking = false |
| @State var sheetShowing = false |
| @State var dismissedNotices: Set<String> = [] |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index cdfc3f6..c0a071d 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -9,8 +9,9 @@ |
| } |
| /// The full-window call: the picture edge to edge, to the very top of |
| - /// the window, with the traffic lights, the title, the controls and the |
| - /// panels over it. There is no title bar: a strip across the top drags |
| + /// the window, with the traffic lights, the controls and the panels over |
| + /// it, and — for a meeting someone named — its title, centred where the |
| + /// window's title would be (CallTitle). There is no title bar: a strip across the top drags |
| /// the window instead. The controls and the traffic lights come with the |
| /// pointer and go 3 s after it stops, or 1.5 s after it leaves the |
| /// window (see ControlsVisibility for what keeps them up). Notices that |
| @@ -55,6 +56,7 @@ |
| .ignoresSafeArea() |
| .background( |
| CallWindowChrome( |
| + title: CallTitle.window(title: call.title), |
| buttonsVisible: chrome.visible, |
| fade: ControlsVisibility.fadeDuration(showing: chrome.visible, reduceMotion: reduceMotion))) |
| .background( |
| @@ -96,10 +98,16 @@ |
| /// A shade down from the top edge while the controls show, so the |
| /// traffic lights and the title read over a bright picture. |
| private var topShade: some View { |
| + // Dark enough through the title bar's strip for a named meeting's |
| + // title, white, over a white picture (4.5:1), then fading. |
| LinearGradient( |
| - colors: [.black.opacity(0.5), .black.opacity(0)], startPoint: .top, endPoint: .bottom |
| + stops: [ |
| + .init(color: .black.opacity(0.72), location: 0), |
| + .init(color: .black.opacity(0.62), location: Self.titleBarHeight / 112), |
| + .init(color: .black.opacity(0), location: 1), |
| + ], startPoint: .top, endPoint: .bottom |
| ) |
| - .frame(height: 96) |
| + .frame(height: 112) |
| .frame(maxHeight: .infinity, alignment: .top) |
| .opacity(chrome.visible ? 1 : 0) |
| .allowsHitTesting(false) |
| @@ -289,14 +297,10 @@ |
| private func selfInsets(in size: CGSize) -> EdgeInsets { |
| var trailing: CGFloat = 0 |
| if showingChat { trailing += Self.panelWidth + 12 } |
| - // The title pill sits beside the traffic lights while the controls |
| - // show; a self view in that corner goes below it. |
| - let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing |
| - let top = |
| - corner == .topLeading && chrome.visible |
| - ? max(Self.titleBarHeight, 6 + titleHeight + 4) : Self.titleBarHeight |
| + // The title bar's strip, with the traffic lights and any title in |
| + // it, is the only thing at the top: a self view goes below it. |
| return EdgeInsets( |
| - top: top, leading: 0, |
| + top: Self.titleBarHeight, leading: 0, |
| bottom: chrome.visible ? Self.barReserve - 12 : 0, trailing: trailing) |
| } |
| @@ -309,40 +313,30 @@ |
| } |
| private var topOverlay: some View { |
| - // A column, so a notice is never under the title however tall the |
| - // title grows; the title keeps its place while faded, so the notices |
| - // do not jump as the controls come and go. |
| - VStack(spacing: 8) { |
| - // The title and how the call is going, beside the traffic lights. |
| - // They go with the controls; the notices below them do not. |
| - HStack { |
| - VStack(alignment: .leading, spacing: 2) { |
| - Text(call.title).font(.headline).foregroundStyle(.white).lineLimit(1) |
| - Text(call.status).font(.callout).foregroundStyle(CallOverlay.secondaryText) |
| - .lineLimit(1) |
| - .accessibilityAddTraits(.updatesFrequently) |
| - ForEach(notices.filter { !$0.important }) { notice in |
| - Text(notice.text).font(.callout).foregroundStyle(CallOverlay.secondaryText) |
| - .fixedSize(horizontal: false, vertical: true) |
| - } |
| - } |
| - .padding(.horizontal, 12).padding(.vertical, 7) |
| - .callOverlaySurface(RoundedRectangle(cornerRadius: 12, style: .continuous)) |
| - .background(GeometryReader { proxy in |
| - Color.clear.onAppear { titleHeight = proxy.size.height } |
| - .onChange(of: proxy.size.height) { _, height in titleHeight = height } |
| - }) |
| - .accessibilityElement(children: .contain) |
| - .accessibilityIdentifier("callTitle") |
| - .frame(maxWidth: 420, alignment: .leading) |
| - Spacer(minLength: 0) |
| + // No pill (2026-10-02, Paul approved): how the call is going is the |
| + // notices' to say, and the title — only one someone chose — is |
| + // plain text centred in the title bar's strip, level with the |
| + // traffic lights, as a window's title is. The strip's dark shade |
| + // (topShade) keeps it legible over a bright picture, and it fades |
| + // with the controls and the traffic lights. |
| + ZStack(alignment: .top) { |
| + if let shown = CallTitle.shown(title: call.title, named: call.named) { |
| + Text(shown) |
| + .font(.system(size: 13, weight: .semibold)) |
| + .foregroundStyle(.white) |
| + .shadow(color: .black.opacity(0.45), radius: 2, y: 1) |
| + .lineLimit(1).truncationMode(.tail) |
| + // Clear of the traffic lights on the left and the same on the |
| + // right, so it stays centred on the window. |
| + .padding(.horizontal, Self.windowButtonsArea.maxX + 8) |
| + .frame(maxWidth: .infinity) |
| + .frame(height: Self.titleBarHeight) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(false) |
| + .accessibilityHidden(!chrome.visible) |
| + .accessibilityAddTraits(.isHeader) |
| + .accessibilityIdentifier("callTitle") |
| } |
| - // Right of the traffic lights, level with them. |
| - .padding(.leading, Self.windowButtonsArea.maxX + 4).padding(.top, 6) |
| - .padding(.trailing, 12) |
| - .opacity(chrome.visible ? 1 : 0) |
| - .allowsHitTesting(chrome.visible) |
| - .accessibilityHidden(!chrome.visible) |
| VStack(spacing: 8) { |
| ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { |
| @@ -356,8 +350,17 @@ |
| } |
| .modifier(NoticeSurface()) |
| } |
| + // A notice that is not urgent — the relay warning — goes with |
| + // the controls; it lived in the pill until the pill went. |
| + ForEach(notices.filter { !$0.important }) { notice in |
| + noticeView(notice) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(chrome.visible) |
| + .accessibilityHidden(!chrome.visible) |
| + } |
| } |
| .padding(.horizontal, 90) |
| + .padding(.top, Self.titleBarHeight + 6) |
| } |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| } |
| @@ -370,7 +373,7 @@ |
| .foregroundStyle(CallOverlay.alertText).bold() |
| case .screenRecovering: |
| Label(notice.text, systemImage: "arrow.clockwise") |
| - case .reconnecting, .waitingToJoin: |
| + case .connecting, .reconnecting, .waitingToJoin: |
| // Drawn rather than AppKit's spinner, which stays dark-on-dark |
| // on this surface in a light-appearance app. |
| Image(systemName: "arrow.triangle.2.circlepath") |
| @@ -540,19 +543,27 @@ |
| /// back while the call is up (0.3.4 showed an opaque bar over the call); |
| /// so while this view is in the window it watches those properties and |
| /// sets them again whenever something else changes them. |
| + /// |
| + /// The window's title is the meeting's while the call is in it, named |
| + /// or not (CallTitle.window): hidden on screen with the rest of the |
| + /// title bar, it is still what the Window menu, Mission Control and |
| + /// VoiceOver read. It is put back with everything else. |
| struct CallWindowChrome: NSViewRepresentable { |
| + var title: String |
| var buttonsVisible: Bool |
| var fade: TimeInterval |
| func makeNSView(context: Context) -> ChromeView { ChromeView() } |
| func updateNSView(_ view: ChromeView, context: Context) { |
| + view.setTitle(title) |
| view.setButtons(visible: buttonsVisible, fade: fade) |
| } |
| static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.release() } |
| final class ChromeView: NSView { |
| private weak var styled: NSWindow? |
| - private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool, separator: NSTitlebarSeparatorStyle)? |
| + private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool, separator: NSTitlebarSeparatorStyle, text: String)? |
| + private var title = "" |
| private var watches: [NSKeyValueObservation] = [] |
| private var observers: [NSObjectProtocol] = [] |
| private var buttonsVisible = true |
| @@ -568,9 +579,11 @@ |
| styled = window |
| saved = ( |
| window.titlebarAppearsTransparent, window.titleVisibility, |
| - window.styleMask.contains(.fullSizeContentView), window.titlebarSeparatorStyle |
| + window.styleMask.contains(.fullSizeContentView), window.titlebarSeparatorStyle, |
| + window.title |
| ) |
| apply(to: window) |
| + if !title.isEmpty { window.title = title } |
| showButtons(animated: false) |
| watches = [ |
| window.observe(\.titlebarAppearsTransparent) { [weak self] window, _ in |
| @@ -579,6 +592,15 @@ |
| window.observe(\.styleMask) { [weak self] window, _ in |
| MainActor.assumeIsolated { self?.apply(to: window) } |
| }, |
| + // SwiftUI writes the scene's own title ("Calls") back now and |
| + // then; the meeting's goes back over it. |
| + window.observe(\.title) { [weak self] window, _ in |
| + MainActor.assumeIsolated { |
| + guard let self, window === self.styled, !self.title.isEmpty, window.title != self.title |
| + else { return } |
| + window.title = self.title |
| + } |
| + }, |
| ] |
| let center = NotificationCenter.default |
| observers = [ |
| @@ -603,6 +625,11 @@ |
| if window.titlebarSeparatorStyle != .none { window.titlebarSeparatorStyle = .none } |
| } |
| + func setTitle(_ text: String) { |
| + title = text |
| + if let window = styled, window.title != text { window.title = text } |
| + } |
| + |
| func setButtons(visible: Bool, fade: TimeInterval) { |
| guard visible != buttonsVisible else { return } |
| buttonsVisible = visible |
| @@ -641,6 +668,7 @@ |
| window.titlebarAppearsTransparent = saved.transparent |
| window.titleVisibility = saved.title |
| window.titlebarSeparatorStyle = saved.separator |
| + window.title = saved.text |
| if !saved.full { window.styleMask.remove(.fullSizeContentView) } |
| } |
diff --git a/native/apple/Sources/CallsKit/SessionStore.swift b/native/apple/Sources/CallsKit/SessionStore.swift| index 0fa0fe9..3e3767e 100644 |
| --- a/native/apple/Sources/CallsKit/SessionStore.swift |
| +++ b/native/apple/Sources/CallsKit/SessionStore.swift |
| @@ -118,6 +118,21 @@ struct SessionRemote: Sendable { |
| var open: @Sendable (_ origin: String, _ cookies: String, _ address: String) throws -> String |
| /// Best effort; see bridge.SignOut. |
| var signOut: @Sendable (_ origin: String, _ cookies: String) -> Void |
| + /// Starts the browser handoff for an address: an ordinary one with no |
| + /// offer, or — for a link from the web's "Join in the app" — one the |
| + /// browser that made the offer confirms. See bridge.BeginAuthentication |
| + /// and bridge.BeginOfferAuthentication. |
| + var begin: @Sendable (_ address: String, _ offer: String?) throws -> any Handoff = { address, offer in |
| + var error: NSError? |
| + let auth = |
| + if let offer { |
| + BridgeBeginOfferAuthentication(address, offer, &error) |
| + } else { |
| + BridgeBeginAuthentication(address, &error) |
| + } |
| + guard let auth else { throw error ?? NSError(domain: "Calls", code: 1) } |
| + return BridgeHandoff(auth) |
| + } |
| static let member = "member" |
| static let participant = "participant" |
| @@ -144,3 +159,31 @@ struct SessionRemote: Sendable { |
| BridgeSignOut(origin, cookies, &error) |
| }) |
| } |
| + |
| +/// One browser handoff in progress: where its browser goes, the code both |
| +/// sides show, and the wait for the session the browser approves. A |
| +/// protocol so the model's handling of it can be tested without a server. |
| +protocol Handoff: AnyObject, Sendable { |
| + func browserURL() -> String |
| + func confirmationCode() -> String |
| + /// Blocks until the browser approves, refuses or the handoff expires; |
| + /// the JSON a sign-in hands back (see CallsModel.adopt). |
| + func wait() throws -> String |
| + func cancel() |
| +} |
| + |
| +/// The bridge's handoff. gomobile's class is safe to use from any thread |
| +/// (the Go side locks); it is simply not marked so. |
| +final class BridgeHandoff: Handoff, @unchecked Sendable { |
| + private let auth: BridgeAuthentication |
| + init(_ auth: BridgeAuthentication) { self.auth = auth } |
| + func browserURL() -> String { auth.browserURL() } |
| + func confirmationCode() -> String { auth.confirmationCode() } |
| + func wait() throws -> String { |
| + var error: NSError? |
| + let result = auth.wait(&error) |
| + if let error { throw error } |
| + return result |
| + } |
| + func cancel() { auth.cancel() } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift| index d750c01..28d290e 100644 |
| --- a/native/apple/Sources/CallsKit/Wire.swift |
| +++ b/native/apple/Sources/CallsKit/Wire.swift |
| @@ -124,6 +124,9 @@ func json(_ value: Any) -> String? { |
| struct MeetingInfo: Decodable { |
| let title: String |
| + /// A title someone chose rather than one Calls wrote. Absent from a |
| + /// server before 0.4.7, where it reads as false and no title is shown. |
| + let named: Bool? |
| let name: String |
| let standing: String |
| let floor: Bool |
diff --git a/native/apple/Tests/CallsKitTests/AppLinkTests.swift b/native/apple/Tests/CallsKitTests/AppLinkTests.swift| new file mode 100644 |
| index 0000000..31438f3 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/AppLinkTests.swift |
| @@ -0,0 +1,191 @@ |
| +import Foundation |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// A handoff the browser never sees: it says what it was begun with, and |
| +/// hands back `result` when the test lets it. |
| +final class FakeHandoff: Handoff, @unchecked Sendable { |
| + private let gate = DispatchSemaphore(value: 0) |
| + private let result: String |
| + private(set) var cancelled = false |
| + init(result: String) { self.result = result } |
| + func browserURL() -> String { "https://calls.oficina.works/native/start?id=x" } |
| + func confirmationCode() -> String { "ABCD-1234" } |
| + func wait() throws -> String { |
| + gate.wait() |
| + if cancelled { throw NSError(domain: "test", code: 1) } |
| + return result |
| + } |
| + func approve() { gate.signal() } |
| + func cancel() { |
| + cancelled = true |
| + gate.signal() |
| + } |
| +} |
| + |
| +final class BeginLog: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var calls: [String] = [] |
| + var log: [String] { lock.withLock { calls } } |
| + func add(_ s: String) { lock.withLock { calls.append(s) } } |
| +} |
| + |
| +@MainActor private func settle(_ done: @MainActor () -> Bool) async { |
| + let deadline = Date().addingTimeInterval(5) |
| + while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| +} |
| + |
| +private let offer = String(repeating: "A", count: 42) + "w" |
| +/// A server that cannot be reached (.invalid never resolves), so that a |
| +/// test that ends in a join sends nothing anywhere. |
| +private let origin = "https://calls.invalid" |
| +private let kept = StoredSession( |
| + origin: origin, cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) |
| + |
| +@MainActor private func makeModel(_ store: SessionStore, _ remote: SessionRemote) -> CallsModel { |
| + let defaults = UserDefaults(suiteName: "AppLinkTests")! |
| + defaults.removePersistentDomain(forName: "AppLinkTests") |
| + defaults.set(origin, forKey: CallsServer.key) |
| + return CallsModel(store: store, remote: remote, defaults: defaults) |
| +} |
| + |
| +private func link(_ path: String, offer: String? = nil) -> URL { |
| + var url = "oficina-calls://join?url=https%3A%2F%2Fcalls.invalid%2F" |
| + + path.replacingOccurrences(of: "/", with: "%2F") |
| + if let offer { url += "&offer=" + offer } |
| + return URL(string: url)! |
| +} |
| + |
| +/// The web's "Join in the app" (2026-10-02): reported by a Calls user as |
| +/// "App link doesn't work if you're signed in". The link named the |
| +/// meeting and nothing else, and the app has a session of its own or none, |
| +/// so a guest signed in on the web — or anyone whose default browser was |
| +/// not the one they were signed in to — could not get there. |
| +final class AppLinkTests: XCTestCase { |
| + private func remote(_ server: ScriptedRemote, _ begun: BeginLog, _ handoff: FakeHandoff) |
| + -> SessionRemote |
| + { |
| + var remote = server.remote |
| + remote.begin = { address, offer in |
| + begun.add("\(address) \(offer ?? "-")") |
| + return handoff |
| + } |
| + return remote |
| + } |
| + |
| + /// Signed out, from the web: the offer is taken up at once, no browser |
| + /// is opened (the one the person came from is waiting), and once it |
| + /// confirms, the app joins that meeting as that person. |
| + @MainActor func testAnOfferFromTheWebIsTakenUpAndJoins() async { |
| + // Asked once, when the sign-in is adopted: a guest's is not kept. |
| + let server = ScriptedRemote(checks: [.success("participant")]) |
| + let begun = BeginLog() |
| + let handoff = FakeHandoff( |
| + result: |
| + #"{"origin":"https://calls.invalid","room":"abc123","cookies":[{"name":"__Host-rastrillo_session","value":"guest"}]}"# |
| + ) |
| + let model = makeModel(MemorySessionStore(), remote(server, begun, handoff)) |
| + model.openMeetingURL(link("m/abc123", offer: offer)) |
| + XCTAssertTrue(model.authenticating) |
| + XCTAssertTrue(model.handingOff, "confirmed in the browser it came from") |
| + XCTAssertEqual(model.pendingLink, "", "started, not offered") |
| + await settle { !model.confirmationCode.isEmpty } |
| + XCTAssertEqual(begun.log, ["https://calls.invalid/m/abc123 \(offer)"]) |
| + XCTAssertEqual(model.confirmationCode, "ABCD-1234") |
| + handoff.approve() |
| + // The join is real and calls.invalid does not resolve, so it is over |
| + // quickly: joined, or tried and said why it could not. |
| + await settle { model.joining || !model.error.isEmpty } |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertEqual(model.room, "abc123") |
| + XCTAssertTrue(model.joining || !model.error.isEmpty, "the meeting they asked for, joined") |
| + XCTAssertFalse(model.authenticating) |
| + model.leave() |
| + } |
| + |
| + /// Signed in, as somebody that meeting is not for, and no offer: the |
| + /// app says so and offers a sign-in for that meeting only, rather than |
| + /// joining and failing. |
| + @MainActor func testASignInThatCannotJoinSaysSoAndOffersAVisit() async { |
| + let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":"","noAccess":true}"#]) |
| + let begun = BeginLog() |
| + let model = makeModel(MemorySessionStore(kept), remote(server, begun, FakeHandoff(result: ""))) |
| + model.restore() |
| + await settle { model.signedIn && !model.restoring } |
| + model.openMeetingURL(link("m/abc123")) |
| + await settle { !model.opening && !model.pendingLink.isEmpty } |
| + XCTAssertEqual(model.pendingLink, "https://calls.invalid/m/abc123") |
| + XCTAssertTrue(model.pendingNoAccess) |
| + XCTAssertFalse(model.joining) |
| + XCTAssertTrue(model.signedIn, "the app's own sign-in stays") |
| + XCTAssertTrue(begun.log.isEmpty, "offered, not started") |
| + } |
| + |
| + /// Signed in, as somebody that meeting is not for, from the web: the |
| + /// person in that browser is handed over for it, as a visit. |
| + @MainActor func testASignInThatCannotJoinTakesUpTheWebsOffer() async { |
| + let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":"","noAccess":true}"#]) |
| + let begun = BeginLog() |
| + let handoff = FakeHandoff(result: "") |
| + let model = makeModel(MemorySessionStore(kept), remote(server, begun, handoff)) |
| + model.restore() |
| + await settle { model.signedIn && !model.restoring } |
| + model.openMeetingURL(link("m/abc123", offer: offer)) |
| + await settle { !begun.log.isEmpty } |
| + XCTAssertEqual(begun.log, ["https://calls.invalid/m/abc123 \(offer)"]) |
| + XCTAssertTrue(model.handingOff) |
| + model.cancelSignIn() |
| + } |
| + |
| + /// Signed in, as someone who may join: the app's own session opens it, |
| + /// with no browser, offer or not — the case that always worked. |
| + @MainActor func testASignInThatCanJoinNeedsNoBrowser() async { |
| + let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":"abc123"}"#]) |
| + let begun = BeginLog() |
| + let model = makeModel(MemorySessionStore(kept), remote(server, begun, FakeHandoff(result: ""))) |
| + model.restore() |
| + await settle { model.signedIn && !model.restoring } |
| + model.openMeetingURL(link("m/abc123", offer: offer)) |
| + await settle { model.joining } |
| + XCTAssertEqual(model.room, "abc123") |
| + XCTAssertTrue(begun.log.isEmpty) |
| + model.leave() |
| + } |
| + |
| + /// The link launched the app with nothing kept: the offer waits for the |
| + /// check of the kept sign-in, then is taken up. |
| + @MainActor func testAnOfferThatLaunchedTheAppIsTakenUpAfterTheCheck() async { |
| + let server = ScriptedRemote() |
| + let begun = BeginLog() |
| + let handoff = FakeHandoff(result: "") |
| + let model = makeModel(MemorySessionStore(), remote(server, begun, handoff)) |
| + model.restore() |
| + model.openMeetingURL(link("m/abc123", offer: offer)) |
| + await settle { !begun.log.isEmpty } |
| + XCTAssertEqual(begun.log, ["https://calls.invalid/m/abc123 \(offer)"]) |
| + model.cancelSignIn() |
| + } |
| + |
| + /// A link on another server, with nobody able to take it up, says which |
| + /// server it is on before anything opens. |
| + @MainActor func testALinkOnAnotherServerSaysWhichServer() { |
| + let model = makeModel(MemorySessionStore(), ScriptedRemote().remote) |
| + model.openMeetingURL( |
| + URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.example.org%2Fm%2Fabc123")!) |
| + XCTAssertEqual(model.pendingLink, "https://calls.example.org/m/abc123") |
| + XCTAssertFalse(model.pendingOnServer) |
| + XCTAssertEqual(model.pendingHost, "calls.example.org") |
| + XCTAssertFalse(model.authenticating) |
| + } |
| + |
| + func testOnlyTheOffersTheServerMakesAreTaken() { |
| + XCTAssertTrue(CallsModel.validOffer(offer)) |
| + XCTAssertTrue(CallsModel.validOffer(String(repeating: "_-", count: 21) + "a")) |
| + for bad in ["", "short", String(repeating: "A", count: 44), String(repeating: "A", count: 42) + "/", |
| + String(repeating: "A", count: 42) + "é"] |
| + { |
| + XCTAssertFalse(CallsModel.validOffer(bad), bad) |
| + } |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift| index e174ddb..19600be 100644 |
| --- a/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| @@ -427,6 +427,49 @@ final class CallNoticesTests: XCTestCase { |
| XCTAssertEqual(notices.map(\.important), [true, true, false], "the relay line fades") |
| } |
| + /// The title pill (until 2026-10-02) said how the call was going beside |
| + /// the title. It is gone, so every state it carried has to be a notice, |
| + /// and one that stays whatever the controls do. |
| + func testEveryStateThePillCarriedIsANotice() { |
| + let states: [(phase: String?, status: String, kind: CallNotice.Kind)] = [ |
| + (nil, "Connecting…", .connecting), |
| + ("connecting", "Connecting…", .connecting), |
| + ("waiting", "Waiting to be let in", .waitingToJoin), |
| + ("full", "The meeting is full. Try joining again shortly.", .full), |
| + ("reconnecting", "Reconnecting…", .reconnecting), |
| + ("ended", "This meeting has ended", .ended), |
| + ] |
| + for state in states { |
| + let notices = CallNotices.notices( |
| + phase: state.phase, status: state.status, sharingScreen: false, screenNote: "", |
| + floor: false) |
| + XCTAssertEqual(notices.map(\.kind), [state.kind], "\(state)") |
| + XCTAssertEqual(notices.first?.text, state.status) |
| + XCTAssertEqual(notices.first?.important, true, "\(state) stays with the controls hidden") |
| + } |
| + } |
| + |
| + /// Anything the call says that is not the quiet "In the meeting" is a |
| + /// notice now — not only what began "Could not", which was all the pill |
| + /// left to the notices before. |
| + func testEveryOtherStatusIsAProblemNotice() { |
| + for status in [ |
| + "Could not send to the meeting. Try again.", "A participant’s media could not connect.", |
| + "Keep the message within 2 KB.", |
| + ] { |
| + let notices = CallNotices.notices( |
| + phase: "admitted", status: status, sharingScreen: false, screenNote: "", floor: false) |
| + XCTAssertEqual(notices.map(\.kind), [.problem], status) |
| + XCTAssertEqual(notices.first?.dismissible, true) |
| + } |
| + for quiet in ["In the meeting", "Ready to join", "You left the meeting", ""] { |
| + XCTAssertEqual( |
| + CallNotices.notices( |
| + phase: nil, status: quiet, sharingScreen: false, screenNote: "", floor: false), [], |
| + quiet) |
| + } |
| + } |
| + |
| func testEndedFullAndWaitingAreImportant() { |
| for (phase, kind) in [ |
| ("ended", CallNotice.Kind.ended), ("full", .full), ("waiting", .waitingToJoin), |
| @@ -568,3 +611,25 @@ final class AudioWarningTests: XCTestCase { |
| "before the microphone starts, the defaults mean nothing") |
| } |
| } |
| + |
| +/// The call's title over the picture (2026-10-02): shown only when someone |
| +/// chose it, and the window's title either way. |
| +final class CallTitleTests: XCTestCase { |
| + func testANamedMeetingShowsItsTitle() { |
| + XCTAssertEqual(CallTitle.shown(title: "Board meeting", named: true), "Board meeting") |
| + XCTAssertEqual(CallTitle.shown(title: " Board meeting \n", named: true), "Board meeting") |
| + } |
| + |
| + func testAnAutomaticTitleIsNotShown() { |
| + XCTAssertNil(CallTitle.shown(title: "Call, 2 Oct 15:40", named: false)) |
| + // A server from before 0.4.7 says nothing, which reads as not named. |
| + XCTAssertNil(CallTitle.shown(title: "Call, 2 Oct 15:40 Europe/Dublin", named: false)) |
| + XCTAssertNil(CallTitle.shown(title: " ", named: true), "nothing to show is nothing shown") |
| + } |
| + |
| + func testTheWindowIsAlwaysTitledForTheMeeting() { |
| + XCTAssertEqual(CallTitle.window(title: "Call, 2 Oct 15:40"), "Call, 2 Oct 15:40") |
| + XCTAssertEqual(CallTitle.window(title: "Board meeting"), "Board meeting") |
| + XCTAssertEqual(CallTitle.window(title: ""), "Calls") |
| + } |
| +} |
diff --git a/native/architecture.md b/native/architecture.md| index 9d3ed14..c6ff733 100644 |
| --- a/native/architecture.md |
| +++ b/native/architecture.md |
| @@ -24,3 +24,14 @@ nor a session token. Request/exchange honor no ambient cookie, forbid redirects |
| in the client and expose no CORS access. Cancellation stops the app's polling; |
| the unconsumed server request expires. This is a Calls session handoff, not an |
| OAuth authorization server or a replacement for Home assertion verification. |
| + |
| +The web's **Join in the app** starts the same handoff from the other end |
| +(2026-10-02). `GET /m/{sqid}/app`, with the browser's session and standing, |
| +makes an *offer*: random, five minutes, one per browser session, bound to that |
| +session's cookie hash and the meeting. The app link carries it. The app's |
| +`/native/request` names it (`BeginOfferAuthentication`), which binds that one |
| +request to it — a second claim is 410 — and the waiting page in the browser |
| +that made the offer (`/native/offer?o=…`, readable only by that session) turns |
| +into `/native/authorize` for the request. Approval and exchange are unchanged. |
| +The offer is not a credential: a claim yields only a request id, and only the |
| +offering browser can see which request claimed it or approve it. |
diff --git a/native/bridge/auth.go b/native/bridge/auth.go| index 5632c86..1f0b3f8 100644 |
| --- a/native/bridge/auth.go |
| +++ b/native/bridge/auth.go |
| @@ -15,6 +15,18 @@ func BeginAuthentication(address string) (*Authentication, error) { |
| } |
| return &Authentication{auth: a}, nil |
| } |
| + |
| +// BeginOfferAuthentication is the handoff for a link from the web's |
| +// "Join in the app", which carries the offer its browser made: that |
| +// browser asks the person to confirm, so the app opens none. See |
| +// callscore.BeginOfferAuthentication. |
| +func BeginOfferAuthentication(address, offer string) (*Authentication, error) { |
| + a, e := callscore.BeginOfferAuthentication(context.Background(), address, offer) |
| + if e != nil { |
| + return nil, e |
| + } |
| + return &Authentication{auth: a}, nil |
| +} |
| func (a *Authentication) BrowserURL() string { return a.auth.BrowserURL() } |
| func (a *Authentication) Cancel() { a.auth.Cancel() } |
| func (a *Authentication) Wait() (string, error) { |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| index 1c84c10..5831dfb 100644 |
| --- a/native/bridge/bridge.go |
| +++ b/native/bridge/bridge.go |
| @@ -67,12 +67,14 @@ func sessionJar(origin, cookiesJSON string) (*cookiejar.Jar, error) { |
| // 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) { |
| +// |
| +// zone is the device's IANA time zone, for the title the server writes. |
| +func StartMeeting(origin, cookiesJSON, zone string) (string, error) { |
| jar, err := sessionJar(origin, cookiesJSON) |
| if err != nil { |
| return "", err |
| } |
| - return callscore.StartMeeting(context.Background(), origin, &http.Client{Jar: jar}) |
| + return callscore.StartMeetingIn(context.Background(), origin, zone, &http.Client{Jar: jar}) |
| } |
| // NewClient uses the ordinary session returned by the browser sign-in handoff. |
| @@ -198,14 +200,16 @@ func CheckSession(origin, cookiesJSON string) (string, error) { |
| // OpenLink resolves a meeting or invitation address on the instance the |
| // host is signed in to, without the browser, and answers JSON: |
| -// {"room": "..."} — empty for the instance's own address — or |
| +// {"room": "..."} — empty for the instance's own address —, |
| // {"signedOut": true} when the session has gone and the address needs a |
| -// fresh sign-in. JSON rather than a bare room so that "no meeting" and |
| -// "no session" cannot be mistaken for each other. |
| +// fresh sign-in, or {"noAccess": true} when the session is fine but is |
| +// not someone who may enter that meeting. JSON rather than a bare room |
| +// so that "no meeting" and "no session" cannot be mistaken for each other. |
| func OpenLink(origin, cookiesJSON, address string) (string, error) { |
| type answer struct { |
| Room string `json:"room"` |
| SignedOut bool `json:"signedOut,omitempty"` |
| + NoAccess bool `json:"noAccess,omitempty"` |
| } |
| var out answer |
| jar, err := sessionJar(origin, cookiesJSON) |
| @@ -216,6 +220,8 @@ func OpenLink(origin, cookiesJSON, address string) (string, error) { |
| } |
| if errors.Is(err, callscore.ErrSignedOut) { |
| out = answer{SignedOut: true} |
| + } else if errors.Is(err, callscore.ErrNoAccess) { |
| + out = answer{NoAccess: true} |
| } else if err != nil { |
| return "", err |
| } |
diff --git a/native/bridge/bridge_test.go b/native/bridge/bridge_test.go| index 8b9ec9d..701f3b5 100644 |
| --- a/native/bridge/bridge_test.go |
| +++ b/native/bridge/bridge_test.go |
| @@ -90,6 +90,8 @@ func TestOpenLinkKeepsNoMeetingAndNoSessionApart(t *testing.T) { |
| http.Redirect(w, r, "/m/abc123/join", http.StatusSeeOther) |
| case "/j/gone": |
| http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| + case "/m/xyz99/info": |
| + w.Write([]byte(`{"title":"x"}`)) |
| default: |
| http.NotFound(w, r) |
| } |
| @@ -100,6 +102,8 @@ func TestOpenLinkKeepsNoMeetingAndNoSessionApart(t *testing.T) { |
| server.URL + "/j/live": `{"room":"abc123"}`, |
| server.URL + "/j/gone": `{"room":"","signedOut":true}`, |
| server.URL + "/m/xyz99": `{"room":"xyz99"}`, |
| + // Signed in, as somebody this meeting is not for (2026-10-02). |
| + server.URL + "/m/other1": `{"room":"","noAccess":true}`, |
| } { |
| got, err := OpenLink(server.URL, cookies, address) |
| if err != nil || got != want { |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index 6640a90..b4b0129 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -272,23 +272,22 @@ extension PhoneCall { |
| private func topOverlay(safe: EdgeInsets) -> some View { |
| VStack(spacing: 8) { |
| - // The title goes with the controls; the notices under it do not. |
| - VStack(alignment: .leading, spacing: 2) { |
| - Text(call.title).font(.headline).foregroundStyle(.white).lineLimit(2) |
| - Text(call.status).font(.caption).foregroundStyle(CallOverlay.secondaryText) |
| - .accessibilityAddTraits(.updatesFrequently) |
| - ForEach(notices.filter { !$0.important }) { notice in |
| - Text(notice.text).font(.caption).foregroundStyle(CallOverlay.secondaryText) |
| - .fixedSize(horizontal: false, vertical: true) |
| - } |
| + // No pill (2026-10-02, Paul approved): how the call is going is the |
| + // notices' to say. A title someone chose is a small centred line at |
| + // the top, inside the safe area, fading with the controls; a title |
| + // Calls wrote is not shown (CallTitle). |
| + if let shown = CallTitle.shown(title: call.title, named: call.named) { |
| + Text(shown) |
| + .font(.footnote.weight(.semibold)).foregroundStyle(.white) |
| + .shadow(color: .black.opacity(0.5), radius: 2, y: 1) |
| + .lineLimit(1).truncationMode(.tail) |
| + .frame(maxWidth: .infinity) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(false) |
| + .accessibilityHidden(!chrome.visible) |
| + .accessibilityAddTraits(.isHeader) |
| + .accessibilityIdentifier("callTitle") |
| } |
| - .padding(.horizontal, 12).padding(.vertical, 8) |
| - .callOverlaySurface(RoundedRectangle(cornerRadius: 14, style: .continuous)) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - .opacity(chrome.visible ? 1 : 0) |
| - .allowsHitTesting(chrome.visible) |
| - .accessibilityHidden(!chrome.visible) |
| - .accessibilityIdentifier("callTitle") |
| ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { notice in |
| noticeView(notice) |
| @@ -300,6 +299,13 @@ extension PhoneCall { |
| } |
| .modifier(PhoneNoticeSurface()) |
| } |
| + // The relay warning goes with the controls; it lived in the pill. |
| + ForEach(notices.filter { !$0.important }) { notice in |
| + noticeView(notice) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(chrome.visible) |
| + .accessibilityHidden(!chrome.visible) |
| + } |
| } |
| .padding(.top, max(safe.top, 12) + 4) |
| .padding(.leading, safe.leading + 12).padding(.trailing, safe.trailing + 12) |
| @@ -314,7 +320,7 @@ extension PhoneCall { |
| .foregroundStyle(CallOverlay.alertText).bold() |
| case .screenRecovering: |
| Label(notice.text, systemImage: "arrow.clockwise") |
| - case .reconnecting, .waitingToJoin: |
| + case .connecting, .reconnecting, .waitingToJoin: |
| ProgressView().tint(.white) |
| Text(notice.text) |
| case .full: |
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index 92525ab..807feba 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -210,6 +210,27 @@ final class CallsPhoneUITests: XCTestCase { |
| XCTAssertTrue(app.textFields["chatComposer"].waitForExistence(timeout: 5)) |
| } |
| + /// No title pill (2026-10-02): a meeting someone named shows its title, |
| + /// small and centred at the top, and nothing about how the call is going |
| + /// beside it; a meeting Calls named ("Call, 24 Sep 10:21") shows none. |
| + @MainActor func testTheTitleShowsOnlyForANamedMeeting() throws { |
| + XCUIDevice.shared.orientation = .portrait |
| + let named = launchCall("named", ["-CallsFixtureControls", "shown"]) |
| + let title = named.staticTexts["callTitle"] |
| + XCTAssertTrue(title.waitForExistence(timeout: 15)) |
| + XCTAssertEqual(title.label, "Board meeting") |
| + XCTAssertEqual(title.frame.midX, named.frame.midX, accuracy: 2, "centred") |
| + XCTAssertGreaterThan(title.frame.minY, 44, "below the status bar: inside the safe area") |
| + XCTAssertFalse(named.staticTexts["In the meeting"].exists, "no pill saying how the call is going") |
| + try snapshot(named, "named-controls-portrait-light") |
| + let auto = launchCall("focus", ["-CallsFixtureControls", "shown"]) |
| + XCTAssertTrue(auto.buttons["leave"].waitForExistence(timeout: 15)) |
| + XCTAssertFalse(auto.staticTexts["callTitle"].exists, "an automatic title is not shown") |
| + XCTAssertFalse(auto.staticTexts["Call, 24 Sep 10:21"].exists) |
| + XCTAssertFalse(auto.staticTexts["In the meeting"].exists) |
| + try snapshot(auto, "auto-controls-portrait-light") |
| + } |
| + |
| /// The snapshots of the full-screen call and the classic one, light and |
| /// dark, portrait and landscape. |
| @MainActor func testFullScreenCallSnapshots() throws { |
| @@ -219,7 +240,10 @@ final class CallsPhoneUITests: XCTestCase { |
| for orientation in [UIDeviceOrientation.portrait, .landscapeLeft] { |
| XCUIDevice.shared.orientation = orientation |
| let turn = orientation == .portrait ? "portrait" : "landscape" |
| - for (fixture, shown) in [("focus", true), ("focus", false), ("grid", true), ("screen", false)] { |
| + for (fixture, shown) in [ |
| + ("focus", true), ("focus", false), ("grid", true), ("screen", false), ("named", true), |
| + ("named", false), |
| + ] { |
| let app = launchCall( |
| fixture, ["-CallsFixtureControls", shown ? "shown" : "hidden"], dark: dark) |
| XCTAssertTrue(app.descendants(matching: .any)["callStage"].firstMatch.waitForExistence(timeout: 15)) |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 55ad1f9..e82d837 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.4.6' |
| + MARKETING_VERSION: '0.4.7' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index f39030c..250627b 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -2625,11 +2625,11 @@ const run = { |
| const r = await page.evaluate(() => ({ |
| reconnect: window.meet.state().reconnect, |
| status: (document.getElementById('status') || {}).textContent || '', |
| - pill: (document.getElementById('call-state') || {}).textContent || '', |
| + title: (document.getElementById('call-top') || {}).textContent || '', |
| tiles: Array.from(document.querySelectorAll('.calls-tile-notice, [data-hear-notice]')).map((n) => n.textContent), |
| })); |
| if (r.reconnect.streamDown) streamDowns += 1; |
| - const text = [r.status, r.pill, ...r.tiles].join(' | '); |
| + const text = [r.status, r.title, ...r.tiles].join(' | '); |
| if (r.reconnect.call || r.reconnect.peers.length || /Reconnecting/.test(text)) said.push(who + ': ' + text); |
| } |
| await sleep(250); |
| @@ -5213,22 +5213,24 @@ const run = { |
| check(joined.cls < 0.01, 'holding screen → room holds still (CLS ' + joined.cls.toFixed(4) + ' ' + JSON.stringify(joined.shifts) + ')'); |
| - // The title menu: the account menu's order and words. |
| + // The More menu (2026-10-02): the account menu's order and words, |
| + // which the title pill's ☰ menu carried until the pill went. |
| await page.mouse.move(200, 200); |
| - await page.click('#call-nav > summary'); |
| - const menu = (await page.locator('.calls-nav-menu').innerText()).split('\n').map((l) => l.trim()).filter(Boolean); |
| - const want = ['Signed in as', 'Meetings', 'Settings', 'Help', 'Open source', 'Sign out']; |
| + await page.click('#call-more-toggle'); |
| + const menu = (await page.locator('#call-more').innerText()).split('\n').map((l) => l.trim()).filter(Boolean); |
| + const want = ['You’re in this call as paul.', 'Signed in as', 'Meetings', 'Settings', 'Help', 'Open source', 'Sign out', 'End meeting for everyone']; |
| const order = want.map((w) => menu.indexOf(w)); |
| - check(order.every((i, n) => i >= 0 && (n === 0 || i > order[n - 1])), 'the room menu reads ' + want.join(' / ') + ' in that order (' + JSON.stringify(menu) + ')'); |
| + check(order.every((i, n) => i >= 0 && (n === 0 || i > order[n - 1])), 'the More menu reads ' + want.join(' / ') + ' in that order (' + JSON.stringify(menu) + ')'); |
| check(await page.getByText('Sign out', { exact: true }).count() === 1, 'the room has exactly one Sign out'); |
| const onTop = await page.evaluate(() => { |
| - const m = document.querySelector('.calls-nav-menu'); |
| + const m = document.getElementById('call-more'); |
| const r = m.getBoundingClientRect(); |
| const hit = document.elementFromPoint(r.left + r.width / 2, r.top + 12); |
| return !!hit && m.contains(hit); |
| }); |
| - check(onTop, 'the open title menu is above the notices (r3 audit C3)'); |
| - await page.click('#call-nav > summary'); |
| + check(onTop, 'the open More menu is above everything under it'); |
| + check(await page.locator('#call-nav, #call-state').count() === 0, 'there is no title pill or ☰ menu'); |
| + await page.keyboard.press('Escape'); |
| await leaveRoom(page); |
| // The room loaded straight from the list, eight times: the shift |
| @@ -5281,12 +5283,15 @@ const run = { |
| 'no script: the room is still the inset, rounded window, with no page scroll (' + JSON.stringify(gs) + ')'); |
| const bareText = await still.locator('.calls-window').innerText(); |
| check(bareText.includes('The call needs JavaScript') && bareText.includes('Back to the holding screen') && |
| - bareText.includes('You’re in this call as paul.'), |
| + bareText.includes('You’re in this call as paul.') && bareText.includes('Board meeting'), |
| 'no script: the title, the standing, the sentence and the way back are inside the window'); |
| - await still.locator('#call-nav summary').click(); |
| - const menu = await still.locator('.calls-nav-menu').innerText(); |
| - check(/Meetings/.test(menu) && /Help/.test(menu) && /Sign out/.test(menu), |
| - 'no script: the title\'s menu opens, with Meetings, Help and Sign out (' + JSON.stringify(menu) + ')'); |
| + // The header is a plain block (noscript.css): the title, who you |
| + // are, the relay notice and the navigation, all visible, no menu to |
| + // open (2026-10-02: the ☰ menu went with the pill). |
| + const head = await still.locator('#call-top').innerText(); |
| + check(await still.locator('#call-title').isVisible() && /Meetings/.test(head) && /Settings/.test(head) && |
| + /Open source/.test(head) && /Sign out/.test(head) && /You’re in this call as paul\./.test(head), |
| + 'no script: the header shows the title, the standing and Meetings, Settings, Help and Sign out (' + JSON.stringify(head) + ')'); |
| check(await still.locator('.calls-scriptless a[href$="/end"]').count() === 1 && |
| await still.locator('.calls-scriptless form[action$="/share"]').count() === 1, |
| 'no script: Share link and End the meeting are reachable'); |
| @@ -5322,25 +5327,29 @@ const run = { |
| check(await pageA.locator('rst-shell-topbar, [rst-shell-topbar], [rst-shell-bar]').count() === 0, |
| 'the page shell is gone: its navigation is in the title\'s menu'); |
| - // ── The title pill is the title and a few words (2026-09-29) ── |
| - const pill = await pageA.evaluate(() => { |
| + // ── No title pill (2026-10-02): a chosen title is the window's ── |
| + // "Board meeting" is a title someone chose, so it is shown as a Mac |
| + // window shows its title: plain text, centred in the strip at the |
| + // top, with nothing about how the call is going beside it. |
| + const titled = await pageA.evaluate(() => { |
| + const w = document.querySelector('.calls-window').getBoundingClientRect(); |
| const top = document.getElementById('call-top'); |
| - return { text: top.innerText.trim().replace(/\n+/g, '\n'), height: top.getBoundingClientRect().height }; |
| + const h1 = document.getElementById('call-title').getBoundingClientRect(); |
| + const st = getComputedStyle(top); |
| + return { |
| + text: top.innerText.trim(), named: top.classList.contains('calls-top-named'), |
| + centre: (h1.left + h1.right) / 2 - (w.left + w.right) / 2, top: h1.top - w.top, height: h1.height, |
| + background: st.backgroundColor, radius: parseFloat(st.borderTopLeftRadius), |
| + }; |
| }); |
| - check(pill.text === 'Board meeting\nIn the meeting', |
| - 'the title pill says the title and "In the meeting", as the Mac\'s does (' + JSON.stringify(pill) + ')'); |
| - await pageA.locator('#call-nav summary').click(); |
| - const standing = (await pageA.locator('.calls-nav-standing').innerText()).trim(); |
| - // The standing line opens the menu; then the account menu's order |
| - // (shared chrome R2): who is signed in, Meetings, Settings, Help. |
| - check(standing === 'You’re in this call as paul.' && |
| - await pageA.evaluate(() => { |
| - const p = document.querySelector('.calls-nav-standing'); |
| - const meetings = [...document.querySelectorAll('.calls-nav-menu a')].find((a) => a.textContent === 'Meetings'); |
| - return p === p.parentElement.firstElementChild && !!meetings && !!(p.compareDocumentPosition(meetings) & Node.DOCUMENT_POSITION_FOLLOWING); |
| - }), 'the standing line opens the title\'s menu, above Meetings (' + standing + ')'); |
| - await pageA.keyboard.press('Escape'); |
| - check(!(await pageA.locator('#call-nav').evaluate((d) => d.open)), 'Escape closes the menu'); |
| + check(titled.text === 'Board meeting' && titled.named, |
| + 'a named meeting shows its title and only its title (' + JSON.stringify(titled) + ')'); |
| + check(Math.abs(titled.centre) < 2 && titled.top >= 0 && titled.top + titled.height <= 44, |
| + 'centred in the strip across the top of the window (' + JSON.stringify(titled) + ')'); |
| + check(titled.background === 'rgba(0, 0, 0, 0)' && titled.radius === 0, 'with no pill behind it'); |
| + check(await pageA.locator('#call-nav, #call-state').count() === 0, 'and no ☰ menu or state line'); |
| + check(await pageA.title() === 'Board meeting · Calls', 'the document is titled for the meeting'); |
| + await shot(pageA, '1440-named-title'); |
| const floorText = await pageA.locator('#notices #floor-notice').innerText().catch(() => ''); |
| check(await pageA.locator('#floor-notice').isVisible() && floorText.includes('Calls outside your network may not connect yet.'), |
| 'the relay notice is a notice of its own under the title, not in it'); |
| @@ -5485,7 +5494,7 @@ const run = { |
| await pageA.mouse.click(cx, cy); |
| check(await pageA.locator('#layout-panel').isHidden(), 'a click elsewhere closes it'); |
| await pageA.locator('#call-more-toggle').click(); |
| - const end = pageA.locator('#call-more a'); |
| + const end = pageA.locator('#call-more a.calls-menu-danger'); |
| check((await end.innerText()).trim() === 'End meeting for everyone' && |
| (await end.getAttribute('href')) === '/meetings/' + sqid + '/end' && (await end.getAttribute('target')) === '_blank', |
| 'More holds End meeting for everyone, a link to its own confirm page'); |
| @@ -5598,7 +5607,7 @@ const run = { |
| }, kind); |
| const surfaces = [ |
| ['the bar', async () => { const b = await box(pageA, '.calls-bar-spacer'); return [b.x + b.width / 2, b.y + b.height / 2]; }], |
| - // Below the menu button, where the title has no words. |
| + // The foot of the title strip, beside the words: its scrim. |
| ['the title', async () => { const b = await box(pageA, '#call-top'); return [b.x + 19, b.y + b.height - 5]; }], |
| ]; |
| const ratios = {}; |
| @@ -5709,6 +5718,105 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // The title (2026-10-02, Paul approved): no pill. A call nobody named |
| + // ("Call, 2 Oct 15:40", Start a call) shows no title at all, and says |
| + // how it is going in the notices only; a named one shows its title |
| + // centred (the window scenario); the pill's ☰ menu is the bar's ⋯ More |
| + // menu; and with no script the header is a plain block with the title, |
| + // the standing and the navigation. CALLS_WINDOW_SNAPSHOTS=<dir> keeps |
| + // pictures of each. |
| + async title() { |
| + const out = process.env.CALLS_WINDOW_SNAPSHOTS; |
| + if (out) mkdirSync(out, { recursive: true }); |
| + const shot = async (page, name) => { if (out) await page.screenshot({ path: out + '/web-title-' + name + '.png' }); }; |
| + const ctx = await newContext('title', { cookie: cookieA }); |
| + const page = await ctx.newPage(); |
| + await page.setViewportSize({ width: 1440, height: 900 }); |
| + await page.goto(origin + '/', { waitUntil: 'domcontentloaded' }); |
| + // Start a call, as the list's button does, with no zone: the title |
| + // is the instance's time and does not name the instance's zone. |
| + const started = await page.evaluate(async () => { |
| + const res = await fetch('/meetings/start', { method: 'POST', body: new URLSearchParams(), credentials: 'same-origin' }); |
| + const html = await res.text(); |
| + const m = html.match(/\/m\/([23456789a-z]{10})\/join/); |
| + return { status: res.status, sqid: m && m[1] }; |
| + }); |
| + check(started.status === 200 && !!started.sqid, 'Start a call made a meeting (' + JSON.stringify(started) + ')'); |
| + await page.goto(origin + '/m/' + started.sqid, { waitUntil: 'domcontentloaded' }); |
| + await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 }); |
| + await until(async () => (await state(page)).admitted, 20000); |
| + await page.mouse.move(400, 300); |
| + const auto = await page.evaluate(() => { |
| + const top = document.getElementById('call-top'); |
| + const h1 = document.getElementById('call-title'); |
| + const r = h1.getBoundingClientRect(); |
| + return { |
| + title: h1.textContent, named: top.classList.contains('calls-top-named'), |
| + width: r.width, height: r.height, headerHeight: top.getBoundingClientRect().height, |
| + status: document.getElementById('status').textContent, doc: document.title, |
| + }; |
| + }); |
| + check(/^Call, \d{1,2} [A-Z][a-z]{2} \d{2}:\d{2}$/.test(auto.title), |
| + 'an automatic title reads "Call, <day> <month> <time>", with no zone after it (' + auto.title + ')'); |
| + check(!auto.named && auto.width <= 1 && auto.height <= 1 && auto.headerHeight === 0, |
| + 'and is not shown: no title, no pill, no strip (' + JSON.stringify(auto) + ')'); |
| + check(auto.doc === auto.title + ' · Calls', 'it is still the document\'s title (' + auto.doc + ')'); |
| + check(await page.locator('#call-nav, #call-state').count() === 0, 'there is no pill and no ☰ menu'); |
| + check(auto.status === 'You are the only one here.', 'how the call is going is the notice\'s to say (' + auto.status + ')'); |
| + await shot(page, 'auto-1440'); |
| + |
| + // The pill's menu, now More's. |
| + await page.click('#call-more-toggle'); |
| + const more = (await page.locator('#call-more').innerText()).split('\n').map((l) => l.trim()).filter(Boolean); |
| + const want = ['You’re in this call as paul.', 'Signed in as', 'Meetings', 'Settings', 'Help', 'Open source', 'Sign out', 'End meeting for everyone']; |
| + const at = want.map((w) => more.indexOf(w)); |
| + check(at.every((i, n) => i >= 0 && (n === 0 || i > at[n - 1])), 'More reads ' + want.join(' / ') + ' (' + JSON.stringify(more) + ')'); |
| + check(await page.locator('#call-more a[href="/"]').isVisible(), 'and Meetings in it can be followed'); |
| + await shot(page, 'more-1440'); |
| + await page.keyboard.press('Escape'); |
| + check(await page.locator('#call-more').isHidden(), 'Escape closes More'); |
| + await leaveRoom(page); |
| + |
| + // A guest has More too: their standing, Help and Sign out, no |
| + // member's pages and no End meeting. |
| + const guestCtx = await newContext('title guest'); |
| + const guest = await guestCtx.newPage(); |
| + await guest.setViewportSize({ width: 1440, height: 900 }); |
| + await guest.goto(origin + '/j/' + token, { waitUntil: 'domcontentloaded' }); |
| + await guest.locator('input[name="name"]').fill('Vicky Walsh'); |
| + await Promise.all([guest.waitForURL('**/m/*/join'), guest.locator('form[action$="/guest"] button[type="submit"]').click()]); |
| + await guest.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' }); |
| + await guest.waitForFunction(() => !!window.meet, null, { timeout: 20000 }); |
| + await guest.mouse.move(400, 300); |
| + await guest.click('#call-more-toggle'); |
| + const guestMore = await guest.locator('#call-more').innerText(); |
| + check(/You’re in this call as Vicky Walsh, a guest\./.test(guestMore) && /Open source/.test(guestMore) && |
| + !/Meetings|Settings|End meeting/.test(guestMore), |
| + 'a guest\'s More has their standing and Help, and no member\'s pages (' + JSON.stringify(guestMore) + ')'); |
| + await shot(guest, 'guest-more-1440'); |
| + await guestCtx.close(); |
| + |
| + // No script: the header is a plain block with the title, standing |
| + // and navigation, for a named meeting and an automatic one alike. |
| + const bare = await newContext('title scriptless', { cookie: cookieA, javaScriptEnabled: false }); |
| + for (const [which, id] of [['named', sqid], ['auto', started.sqid]]) { |
| + const still = await bare.newPage(); |
| + await still.setViewportSize({ width: 1440, height: 900 }); |
| + await still.goto(origin + '/m/' + id, { waitUntil: 'domcontentloaded' }); |
| + const head = await still.locator('#call-top').innerText(); |
| + const h1 = await still.locator('#call-title').boundingBox(); |
| + check(h1 && h1.width > 40 && head.includes(which === 'named' ? 'Board meeting' : auto.title) && |
| + /You’re in this call as paul\./.test(head) && /Meetings/.test(head) && /Settings/.test(head) && |
| + /Open source/.test(head) && /Sign out/.test(head), |
| + 'no script, ' + which + ': the title, the standing and the navigation show (' + JSON.stringify(head) + ')'); |
| + await shot(still, 'nojs-' + which + '-1440'); |
| + await still.close(); |
| + } |
| + await bare.close(); |
| + check(errorsFor('title') === '', 'no console errors (' + errorsFor('title') + ')'); |
| + await ctx.close(); |
| + }, |
| + |
| async scriptless() { |
| const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false }); |
| const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.'; |
diff --git a/test/browser/native-applink.mjs b/test/browser/native-applink.mjs| new file mode 100644 |
| index 0000000..9617c55 |
| --- /dev/null |
| +++ b/test/browser/native-applink.mjs |
| @@ -0,0 +1,104 @@ |
| +// native-applink.mjs: the browser's half of TestNativeBrowserAppLink. |
| +// |
| +// The web's "Join in the app" (2026-10-02): a person signed in on the web |
| +// hands themselves to the Calls app. The Go test plays the app — it holds |
| +// the verifier and calls callscore exactly as the app does — and drives |
| +// this script one command at a time over stdin, one JSON line back per |
| +// command, so each step's answer can decide the next: |
| +// |
| +// {"cmd":"context","cookie":{name,value}|null,"js":true} a browser |
| +// {"cmd":"goto","path":"/m/x/join"} → {"url","appHref","body"} |
| +// {"cmd":"click-app"} → {"url","app"} (the offer page) |
| +// {"cmd":"guest","name":"Dee"} → {"url"} (guest form) |
| +// {"cmd":"approve","code":"ABCD-1234"} → {"url","text"} (on /native/complete) |
| +// {"cmd":"quit"} |
| +// |
| +// Every command answers {"error": "..."} instead when it cannot do what |
| +// it says, and the test fails with that. |
| +import { chromium } from 'playwright'; |
| +import { createInterface } from 'node:readline'; |
| + |
| +const lines = createInterface({ input: process.stdin }); |
| +const input = lines[Symbol.asyncIterator](); |
| +const origin = JSON.parse((await input.next()).value).origin; |
| +const browser = await chromium.launch({ headless: true }); |
| +let context = null; |
| +let page = null; |
| +const reply = (o) => process.stdout.write(JSON.stringify(o) + '\n'); |
| + |
| +async function appHref() { |
| + const a = page.locator('a[href^="oficina-calls:"], a:has-text("Join in the app")').first(); |
| + return (await a.count()) ? a.getAttribute('href') : ''; |
| +} |
| + |
| +try { |
| + for (;;) { |
| + const next = await input.next(); |
| + if (next.done) break; |
| + const c = JSON.parse(next.value); |
| + try { |
| + switch (c.cmd) { |
| + case 'context': { |
| + if (context) await context.close(); |
| + context = await browser.newContext({ javaScriptEnabled: c.js !== false }); |
| + if (c.cookie) await context.addCookies([{ name: c.cookie.name, value: c.cookie.value, url: origin }]); |
| + page = await context.newPage(); |
| + reply({ ok: true }); |
| + break; |
| + } |
| + case 'goto': { |
| + await page.goto(origin + c.path, { waitUntil: 'domcontentloaded' }); |
| + reply({ url: page.url(), appHref: await appHref(), body: (await page.locator('body').innerText()).slice(0, 2000) }); |
| + break; |
| + } |
| + case 'click-app': { |
| + await Promise.all([ |
| + // Not "load": with script the page opens oficina-calls:// |
| + // straight away, which a browser with no Calls app never |
| + // finishes, and that is the case here. |
| + page.waitForURL('**/native/offer?o=*', { waitUntil: 'commit' }), |
| + page.getByRole('link', { name: 'Join in the app', exact: true }).click(), |
| + ]); |
| + const app = await page.locator('#open-app').getAttribute('href', { timeout: 10000 }); |
| + reply({ url: page.url(), app }); |
| + break; |
| + } |
| + case 'guest': { |
| + await page.locator('input[name="name"]').fill(c.name); |
| + await Promise.all([ |
| + page.waitForURL((u) => !u.pathname.startsWith('/j/')), |
| + page.locator('form[action$="/guest"] button[type="submit"]').click(), |
| + ]); |
| + reply({ url: page.url() }); |
| + break; |
| + } |
| + case 'approve': { |
| + // The waiting page turns into the confirmation by itself — by |
| + // script, or by its own refresh with none — once the app has |
| + // asked. Nothing here navigates it. |
| + await page.waitForURL('**/native/authorize?id=*', { timeout: 20000, waitUntil: 'domcontentloaded' }); |
| + const text = await page.locator('body').innerText(); |
| + if (!text.includes(c.code)) throw new Error('the confirmation does not show the app\'s code ' + c.code + ': ' + text.slice(0, 400)); |
| + const button = page.getByRole('button', { name: /^(Sign in to Calls app|Continue as guest)$/ }); |
| + await Promise.all([page.waitForURL('**/native/complete'), button.click()]); |
| + reply({ url: page.url(), text: (await page.locator('body').innerText()).slice(0, 400) }); |
| + break; |
| + } |
| + case 'quit': |
| + reply({ ok: true }); |
| + throw new Error('quit'); |
| + default: |
| + reply({ error: 'unknown command ' + c.cmd }); |
| + } |
| + } catch (e) { |
| + if (e.message === 'quit') throw e; |
| + reply({ error: String(e && e.message || e) }); |
| + } |
| + } |
| +} catch (e) { |
| + if (e.message !== 'quit') throw e; |
| +} finally { |
| + await browser.close(); |
| + lines.close(); |
| + process.stdin.destroy(); |
| +} |
diff --git a/test/browser/room-window.test.mjs b/test/browser/room-window.test.mjs| index c979b1a..8ccbcbe 100644 |
| --- a/test/browser/room-window.test.mjs |
| +++ b/test/browser/room-window.test.mjs |
| @@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url'; |
| const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); |
| const windowJS = read('../../internal/calls/static/call-window.js'); |
| +const roomJS = read('../../internal/calls/static/room.js'); |
| const layoutJS = read('../../internal/calls/static/call-layout.js'); |
| const swift = read('../../native/apple/Sources/CallsKit/CallChrome.swift'); |
| const css = read('../../internal/calls/static/calls.css'); |
| @@ -152,14 +153,22 @@ test('a dropped self view settles where it was headed', () => { |
| assert.equal(layout.pipCorner({ x: 450, y: 500 }, { x: 700, y: 100 }, 1000, 600), 'top-right', 'a flick carries it'); |
| }); |
| -test('the title pill\'s few words for how the call is going', () => { |
| +test('every state the title pill carried is a notice now (2026-10-02)', () => { |
| + // The pill said Connecting, Waiting to be let in, The room is full, |
| + // Reconnecting and Call ended over the title. It is gone; the status |
| + // notice (#status, room.js's statusLine) says each of them, and the |
| + // call being over is stop()'s sentence with its way out. |
| // eslint-disable-next-line no-new-func |
| - const callState = new Function(lift(windowJS, 'callState') + '; return callState;')(); |
| - assert.equal(callState(null, ''), 'Connecting'); |
| - assert.equal(callState({ admitted: false }, 'Connecting to the call.'), 'Connecting'); |
| - assert.equal(callState({ admitted: false }, 'Waiting for someone to let you in.'), 'Waiting to be let in'); |
| - assert.equal(callState({ admitted: false }, 'The room is full.'), 'The room is full'); |
| - assert.equal(callState({ admitted: true }, ''), 'In the meeting'); |
| - assert.equal(callState({ admitted: true }, 'You are the only one here.'), 'In the meeting'); |
| - assert.equal(callState({ admitted: false, terminal: 'ended' }, 'This meeting has ended.'), 'Call ended'); |
| + const statusLine = new Function(lift(roomJS, 'statusLine') + '; return statusLine;')(); |
| + assert.deepEqual(statusLine({ admitted: false }, false, 0, true), ['Connecting to the call.', false]); |
| + assert.deepEqual(statusLine({ admitted: false, peer: 'p', joined: true }, false, 0, false), |
| + ['Waiting for someone to let you in.', false]); |
| + assert.deepEqual(statusLine({ admitted: false, peer: 'p', joined: true }, false, 0, true), |
| + ['Connecting to the call.', false], 'a member is never waiting'); |
| + assert.deepEqual(statusLine({ full: true }, false, 0, true), ['The room is full.', false]); |
| + assert.deepEqual(statusLine({ admitted: true }, true, 1, true), ['Reconnecting…', false]); |
| + assert.deepEqual(statusLine({ admitted: true }, false, 0, true), ['You are the only one here.', true]); |
| + assert.deepEqual(statusLine({ admitted: true }, false, 2, true), ['', false], 'in the meeting: nothing to say'); |
| + assert.ok(/This meeting has ended/.test(roomJS), 'the call being over is said'); |
| + assert.ok(!/callState|call-state/.test(windowJS), 'and no pill is drawn'); |
| }); |