Calls: no title pill; Join in the app hands over the person signed in
The title pill beside the traffic lights ("Call, 2 Oct 15:40
Europe/Dublin / In the meeting") is gone on Mac, iPhone and web (Paul
approved). Every state it carried is a notice: the apps' CallNotices
gain a Connecting notice and turn any other unquiet status into one, and
the web's status line is lifted into statusLine so a test can hold it.
A title someone chose is shown as a window title, plain and centred in
the top strip, fading with the controls; one Calls wrote is not shown.
Which is which is an explicit flag (meetings.auto_title), set by Start a
call and the apps' Start meeting and served as /info's "named". The Mac
window's own title is the meeting's for the Window menu, Mission Control
and VoiceOver. The web's pill menu is the bar's More menu, for guests
too; with no script the header is a plain block with the title, standing
and navigation.
The automatic title no longer names its zone. f86d4da added the suffix
so a reader elsewhere was not shown a bare time in an unseen zone; the
apps sent no zone, so every app-started meeting had it. The apps now
send theirs, and the zone is kept as data (meetings.title_zone).
"App link doesn't work if you're signed in": the holding screen's
oficina-calls://join?url=/m/<sqid> named a meeting and not a person.
An app signed in as someone without standing failed at the join, and
an app with none signed in in the default browser, where a guest has
no way to sign in. Join in the app now goes through /m/{sqid}/app, an
offer bound to this browser session; the app's handoff request names
it, and the page the person is on becomes the confirmation for it.
Same verifier exchange, one use, five minutes, the browser session's
standing. OpenLink asks /info before answering a /m/ address, so an
app session without standing is told so and offered a visit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
53 files changed,
+2183
−462
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+54 −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+7 −2internal/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+67 −45native/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 −17test/browser/call.mjs+140 −32test/browser/native-applink.mjs+104 −0test/browser/room-window.test.mjs+18 −9
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..a3e62ee 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -300,75 +300,70 @@ 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: 96px; |
| + background: linear-gradient(rgb(0 0 0 / 0.5), 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 +379,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 +404,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 +413,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 +1415,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..cf51152 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 |
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..733f722 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( |
| @@ -289,14 +291,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 +307,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 +344,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 +367,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 +537,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 +573,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 +586,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 +619,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 +662,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/test/browser/call.mjs b/test/browser/call.mjs| index f39030c..cb87c5d 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'); |
| @@ -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'); |
| }); |