oficina / meet Public

Merge fix/title-and-app-link: no title pill; Join in the app as the person signed in (spec 15.25)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev de04e2d59e5297bf78e33864d42844b1dff06bac parent 6f275f8parent 1747c55
55 files changed, +2225 −470
  • Makefile +1 −1
  • README.md +10 −2
  • callscore/auth.go +38 −2
  • callscore/meetings.go +19 −1
  • callscore/session.go +47 −8
  • callscore/session_test.go +48 −8
  • callscore/transport.go +6 −1
  • docs/superpowers/specs/2026-09-07-meet-design.md +43 −0
  • internal/calls/app.go +2 −0
  • internal/calls/guest.go +1 −1
  • internal/calls/handlers_join.go +6 −1
  • internal/calls/handlers_meetings.go +25 −9
  • internal/calls/handlers_meetings_test.go +77 −14
  • internal/calls/handlers_room.go +4 −2
  • internal/calls/migrations/0006_alter_meetings.sql +4 −0
  • internal/calls/migrations/schema.sql +1 −1
  • internal/calls/models.go +13 −2
  • internal/calls/native_auth.go +171 −7
  • internal/calls/native_auth_test.go +135 −0
  • internal/calls/static/app-offer.js +45 −0
  • internal/calls/static/call-window.js +17 −48
  • internal/calls/static/calls.css +56 −66
  • internal/calls/static/noscript.css +31 −0
  • internal/calls/static/room.js +22 −23
  • internal/calls/store.go +12 −1
  • internal/calls/templates/join.html +1 −1
  • internal/calls/templates/native.html +16 −0
  • internal/calls/templates/room.html +40 −39
  • internal/callstest/browser_test.go +1 −0
  • internal/callstest/chrome_test.go +11 −4
  • internal/callstest/native_applink_browser_test.go +276 −0
  • internal/callstest/room_test.go +90 −59
  • native/README.md +8 −2
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +9 −2
  • native/apple/Sources/CallsKit/Call.swift +6 −1
  • native/apple/Sources/CallsKit/CallChrome.swift +44 −3
  • native/apple/Sources/CallsKit/CallFixtures.swift +6 −0
  • native/apple/Sources/CallsKit/CallsLobby.swift +26 −8
  • native/apple/Sources/CallsKit/CallsModel.swift +129 −36
  • native/apple/Sources/CallsKit/MacCallView.swift +0 −3
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +75 −47
  • native/apple/Sources/CallsKit/SessionStore.swift +43 −0
  • native/apple/Sources/CallsKit/Wire.swift +3 −0
  • native/apple/Tests/CallsKitTests/AppLinkTests.swift +191 −0
  • native/apple/Tests/CallsKitTests/CallChromeTests.swift +65 −0
  • native/architecture.md +11 −0
  • native/bridge/auth.go +12 −0
  • native/bridge/bridge.go +11 −5
  • native/bridge/bridge_test.go +4 −0
  • native/ios/Sources/PhoneFullScreenCall.swift +23 −17
  • native/ios/UITests/CallsPhoneUITests.swift +25 −1
  • native/ios/project.yml +1 −1
  • test/browser/call.mjs +142 −34
  • test/browser/native-applink.mjs +104 −0
  • test/browser/room-window.test.mjs +18 −9

CI passed — run details

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