Merge main into the rename, and finish a class the sweep missed
main landed feat/meet-access-settings (709e4c1) in its pre-rename form:
meeting access defaults, guest joining, a waiting room and native app
links, across 41 files. Git's rename detection carried 29 of them onto the
renamed paths by itself; the rest are resolved here.
Conflicts taken in favour of main's behaviour, with the rename applied on
top rather than the other way round — the feature is newer than the rename
and nothing about it should be lost to it:
native_auth.go their anonymous/guest titles kept, renamed
native.html their {{if .Guest}} branch kept, renamed; the file had
been deleted under me by the directory rename, so their
version was reapplied at the renamed path
join.html their "Join in browser" copy over my "Join the call"
CallsLobby.swift their newer copy, which no longer names the app at all
All eight files main added exist at their renamed paths, and the new
0004_alter_persons.sql migration runs under the "calls" namespace.
The sweep also missed a class of reference in my own earlier commits, not
just in the merged code: identifiers where "Meet" is a prefix rather than a
whole word, in files I had swept with \bMeet\b. That never matches
MeetInterop, MeetPhone, MeetPhoneTests or MeetPhoneUITests, so
internal/callstest's native and iPhone browser tests were still invoking
xcodebuild against target names that no longer exist. Those tests are
build-tag gated, so neither `make ci` nor a green build would ever have
caught it. Everything outside the dated superpowers records is now swept
with the Meeting-safe \bMeet(?!ing) pattern instead.
The one remaining "Meet" is CallsLobby's "Meet face to face.", which is the
English verb.
GOTOOLCHAIN=go1.26.5 make ci passes; swift build and swift test pass (four
tests now, two of them main's); CallsPhone builds for the iOS simulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
43 files changed,
+738
−47
README.md+21 −0internal/calls/app.go+11 −3internal/calls/guest.go+78 −0internal/calls/handlers_join.go+17 −0internal/calls/handlers_room.go+5 −1internal/calls/handlers_settings.go+45 −1internal/calls/home.go+12 −0internal/calls/home_router_test.go+1 −0internal/calls/hub/admission.go+35 −2internal/calls/hub/hub.go+5 −2internal/calls/hub/waiting_room_test.go+59 −0internal/calls/migrations/0004_alter_persons.sql+11 −0internal/calls/migrations/schema.sql+4 −2internal/calls/models.go+22 −10internal/calls/native_auth.go+19 −2internal/calls/native_auth_test.go+60 −0internal/calls/render.go+2 −2internal/calls/standing.go+4 −1internal/calls/store.go+27 −1internal/calls/templates/guest.html+11 −0internal/calls/templates/index.html+1 −1internal/calls/templates/join.html+4 −2internal/calls/templates/meeting_settings.html+12 −0internal/calls/templates/native.html+5 −0internal/calls/templates/partials/meeting-options.html+6 −0internal/calls/templates/settings.html+9 −0internal/callstest/browser_test.go+1 −0internal/callstest/chat_test.go+1 −0internal/callstest/ice_test.go+2 −0internal/callstest/iphone_browser_test.go+2 −2internal/callstest/isolation_test.go+1 −0internal/callstest/join_test.go+3 −0internal/callstest/meeting_settings_test.go+132 −0internal/callstest/native_browser_test.go+1 −1internal/callstest/signal_test.go+2 −0native/apple/Sources/CallsKit/CallsLobby.swift+3 −3native/apple/Sources/CallsKit/CallsModel.swift+27 −0native/apple/Sources/CallsMac/CallsMac.swift+1 −0native/apple/Tests/CallsKitTests/MeetingLinkTests.swift+28 −0native/ios/Sources/CallsPhone.swift+1 −0native/ios/project.yml+6 −0native/scripts/bundle-mac.py+13 −11test/browser/call.mjs+28 −0
CI passed — run details
diff --git a/README.md b/README.md| index de3ee1e..87bd1bb 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -173,3 +173,24 @@ and what it deliberately leaves out; `AGENTS.md` covers the |
| conventions the code is held to. Spec §15.18 is the disposition of |
| record for what slice 6 delivered, deferred (the acknowledgements |
| page) and declined (the Estilo theme pin, the production promotion). |
| + |
| +### Meeting access settings |
| + |
| +Team members can set **Meeting defaults** in `/settings` and change either |
| +option for an individual meeting from its **Settings** link. New meetings |
| +permit guests and have no waiting room unless the team changes those defaults. |
| +Defaults apply to every creation path, including agents. Existing meetings keep |
| +their original sign-in and waiting-room requirements during migration. |
| + |
| +When sign-in is off, an invitation holder can enter a display name to join as |
| +a guest. Guest sessions cannot create meetings, change settings, or admit other |
| +guests. With the waiting room on, a team member admits guests; with it off, |
| +guests enter directly, subject to the same call capacity limit. Changes apply |
| +at entry; admitted participants keep their participation. Turning the waiting room off |
| +admits queued guests up to the call capacity. |
| + |
| +Invitation and browser holding pages offer **Join in the app**, using the |
| +`oficina-meet://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 |
| +remains available when the app is not installed. |
diff --git a/internal/calls/app.go b/internal/calls/app.go| index f3a331a..c0fe7f2 100644 |
| --- a/internal/calls/app.go |
| +++ b/internal/calls/app.go |
| @@ -371,6 +371,10 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| // own lock: hubStanding is a database read. |
| a.hub = hub.New(hub.Config{ |
| Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, |
| + WaitingRoom: func(ctx context.Context, sqid string) (bool, error) { |
| + m, _, err := st.meetingBySqid(ctx, sqid) |
| + return m.WaitingRoom, err |
| + }, |
| }) |
| a.signalLimiter = newRateLimiter(30, 60, maxSignalBuckets) |
| a.apiLimiter = newRateLimiter(apiRatePerSecond, apiBurst, maxAgentBuckets) |
| @@ -440,11 +444,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/auth/local/complete", a.home.routes.LocalComplete) |
| } |
| r.Get("/j/{token}", a.followLink) |
| + r.Post("/j/{token}/guest", a.joinGuest) |
| - // Signed in, with a persons row. |
| + // A verified or guest session, with a persons row. Guest sessions are |
| + // restricted to participation and native authorization routes. |
| r.Group(func(r chi.Router) { |
| - r.Use(authPlugin.RequireSession) |
| - r.Use(a.requirePerson) |
| + r.Use(a.requireParticipant) |
| r.Get("/", a.index) |
| r.Get("/native/complete", a.nativeComplete) |
| r.Get("/native/authorize", a.nativeAuthorize) |
| @@ -474,6 +479,9 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/meetings/{sqid}/end", a.confirmEnd) |
| r.Post("/meetings/{sqid}/end", a.endMeeting) |
| r.Get("/settings", a.settings) |
| + r.Post("/settings/defaults", a.saveDefaults) |
| + r.Get("/meetings/{sqid}/settings", a.meetingSettings) |
| + r.Post("/meetings/{sqid}/settings", a.saveSettings) |
| r.Post("/settings/agents", a.createAgentHandler) |
| r.Get("/settings/agents/{id}/revoke", a.confirmRevokeAgent) |
| r.Post("/settings/agents/{id}/revoke", a.revokeAgentHandler) |
diff --git a/internal/calls/guest.go b/internal/calls/guest.go| new file mode 100644 |
| index 0000000..3fc42b6 |
| --- /dev/null |
| +++ b/internal/calls/guest.go |
| @@ -0,0 +1,78 @@ |
| +package calls |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "unicode/utf8" |
| + |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// Guest sessions use the ordinary revocable session store, with a distinct |
| +// identity method. They can never resolve to a verified address or team member. |
| +func (a *app) requireParticipant(next http.Handler) http.Handler { |
| + return a.auth.RequireSession(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" { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }))) |
| +} |
| +func (a *app) guestPage(w http.ResponseWriter, r *http.Request, m Meeting, token, name, message string) { |
| + nativeHeaders(w) |
| + render(w, "guest", map[string]any{"Title": a.store.title(m), "Token": token, "Name": name, "NameError": message, "NativeURL": nativeJoinURL(a.cfg.Origin + "/j/" + token)}) |
| +} |
| +func (a *app) joinGuest(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + if _, signedIn := a.auth.SessionFrom(r); signedIn { |
| + a.followLink(w, r) |
| + return |
| + } |
| + token := chi.URLParam(r, "token") |
| + link, m, live, err := a.store.liveLink(r.Context(), token) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + if !live { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + if m.RequireSignin { |
| + a.setJoinCookie(w, link.TokenHash) |
| + http.Redirect(w, r, signinPath, 303) |
| + return |
| + } |
| + name := strings.TrimSpace(r.PostFormValue("name")) |
| + if name == "" || utf8.RuneCountInString(name) > 80 { |
| + w.WriteHeader(422) |
| + a.guestPage(w, r, m, token, name, "Enter your name (up to 80 characters).") |
| + return |
| + } |
| + p, err := a.store.createGuest(r.Context(), name) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + _, live, err = a.store.mintGrantViaLink(r.Context(), link.ID, p.ID) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + if !live { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + value, err := a.nativeAuth.sessions.Mint(sessions.Session{Subject: strconv.FormatInt(p.ID, 10), Method: "guest", AuthTime: a.store.now(), At: a.store.now()}) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + http.SetCookie(w, &http.Cookie{Name: a.auth.SessionCookie(), Value: value, Path: "/", HttpOnly: true, Secure: strings.HasPrefix(a.cfg.Origin, "https:"), SameSite: http.SameSiteLaxMode}) |
| + http.Redirect(w, r, "/m/"+m.Sqid+"/join", 303) |
| +} |
diff --git a/internal/calls/handlers_join.go b/internal/calls/handlers_join.go| index a665505..d6aac7b 100644 |
| --- a/internal/calls/handlers_join.go |
| +++ b/internal/calls/handlers_join.go |
| @@ -3,7 +3,9 @@ package calls |
| import ( |
| "amadan.net/rastrillo/rastrillo/sessions" |
| "errors" |
| + "html/template" |
| "net/http" |
| + "net/url" |
| "github.com/go-chi/chi/v5" |
| ) |
| @@ -25,6 +27,10 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| id, signedIn := a.auth.SessionFrom(r) |
| if !signedIn { |
| a.setJoinCookie(w, link.TokenHash) |
| + if !m.RequireSignin { |
| + a.guestPage(w, r, m, chi.URLParam(r, "token"), "", "") |
| + return |
| + } |
| http.Redirect(w, r, signinPath, http.StatusSeeOther) |
| return |
| } |
| @@ -38,6 +44,11 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| + if p.Anonymous && m.RequireSignin { |
| + a.setJoinCookie(w, link.TokenHash) |
| + http.Redirect(w, r, signinPath, 303) |
| + return |
| + } |
| if !a.isMember(p) { |
| // Via the link, not the meeting: this handler read the link |
| // before it resolved the person, and a member may have replaced |
| @@ -77,5 +88,11 @@ func (a *app) holding(w http.ResponseWriter, r *http.Request) { |
| "Sqid": m.Sqid, "Title": a.store.title(m), "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), |
| })) |
| } |
| + |
| +// Only app-constructed meeting URLs are promoted to the custom URL scheme. |
| +func nativeJoinURL(meeting string) template.URL { |
| + return template.URL("oficina-meet://join?url=" + url.QueryEscape(meeting)) |
| +} |
diff --git a/internal/calls/handlers_room.go b/internal/calls/handlers_room.go| index e006f81..45d8f68 100644 |
| --- a/internal/calls/handlers_room.go |
| +++ b/internal/calls/handlers_room.go |
| @@ -45,7 +45,11 @@ func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Perso |
| // longer a way into this page. Relabelling one as a guest would |
| // hand an ex-member the room for as long as the hub happened to |
| // hold their participation, which is the opposite of the rule. |
| - _, kind, live := a.hub.LiveKindForSession(sqid, session) |
| + peer, kind, live := a.hub.LiveKindForSession(sqid, session) |
| + if p.Anonymous && m.RequireSignin { |
| + participant, found := a.hub.Find(sqid, session, peer) |
| + live = live && found && participant.Admitted |
| + } |
| if !live || kind == hub.KindMember { |
| a.ErrorPage(w, r, http.StatusNotFound, "") |
| return Meeting{}, Person{}, StandingNone, false |
diff --git a/internal/calls/handlers_settings.go b/internal/calls/handlers_settings.go| index fea9fab..0270f25 100644 |
| --- a/internal/calls/handlers_settings.go |
| +++ b/internal/calls/handlers_settings.go |
| @@ -50,9 +50,14 @@ func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int, |
| Revoked: ag.RevokedAt != nil, Principal: principal(ag, p), |
| }) |
| } |
| + defaults, err := a.store.meetingDefaults(r.Context()) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| w.WriteHeader(status) |
| render(w, "settings", a.page(r, map[string]any{ |
| - "Agents": rows, "Name": name, "NameError": nameErr, |
| + "Defaults": defaults, "Saved": r.URL.Query().Get("saved") == "1", "Agents": rows, "Name": name, "NameError": nameErr, |
| })) |
| } |
| @@ -146,3 +151,42 @@ func (a *app) revokeAgentHandler(w http.ResponseWriter, r *http.Request) { |
| } |
| http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| } |
| + |
| +func (a *app) saveDefaults(w http.ResponseWriter, r *http.Request) { |
| + if err := a.store.saveMeetingDefaults(r.Context(), r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) |
| +} |
| +func (a *app) meetingSettings(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + if m.EndedAt != nil { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + render(w, "meeting_settings", a.page(r, map[string]any{"Meeting": m, "Title": a.store.title(m), "Saved": r.URL.Query().Get("saved") == "1"})) |
| +} |
| +func (a *app) saveSettings(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + if m.EndedAt != nil { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + if err := a.store.saveMeetingSettings(r.Context(), m.ID, r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + if r.PostFormValue("waiting_room") != "on" { |
| + if err := a.hub.RefreshWaiting(r.Context(), m.Sqid); err != nil { |
| + a.logger.Error("calls: refresh waiting room", "err", err) |
| + } |
| + } |
| + http.Redirect(w, r, "/meetings/"+m.Sqid+"/settings?saved=1", http.StatusSeeOther) |
| +} |
diff --git a/internal/calls/home.go b/internal/calls/home.go| index f18b5ad..08c2b44 100644 |
| --- a/internal/calls/home.go |
| +++ b/internal/calls/home.go |
| @@ -6,6 +6,7 @@ import ( |
| "database/sql" |
| "errors" |
| "net/http" |
| + "strconv" |
| "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/home/connect/linkstore" |
| @@ -78,6 +79,17 @@ func (a *app) wireHome() error { |
| } |
| func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person, error) { |
| + if s.Method == "guest" { |
| + id, err := strconv.ParseInt(s.Subject, 10, 64) |
| + if err != nil { |
| + return Person{}, errHomeIdentity |
| + } |
| + p, ok, err := a.store.personByID(ctx, id) |
| + if err != nil || !ok || !p.Anonymous { |
| + return Person{}, errHomeIdentity |
| + } |
| + return p, nil |
| + } |
| if s.Method == connect.HomeMethod { |
| if a.home == nil { |
| return Person{}, errHomeIdentity |
diff --git a/internal/calls/home_router_test.go b/internal/calls/home_router_test.go| index 3fb68c6..39ab1bf 100644 |
| --- a/internal/calls/home_router_test.go |
| +++ b/internal/calls/home_router_test.go |
| @@ -204,6 +204,7 @@ func TestHomeSessionFailsWhenDisabled(t *testing.T) { |
| func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| f.local(t) |
| + f.do("POST", "/settings/defaults", "require_signin=on") |
| made := f.do("POST", "/meetings", "title=Guest+meeting") |
| token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String()) |
| sqid := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(made.Body.String()) |
diff --git a/internal/calls/hub/admission.go b/internal/calls/hub/admission.go| index 9b96e41..3a97e62 100644 |
| --- a/internal/calls/hub/admission.go |
| +++ b/internal/calls/hub/admission.go |
| @@ -205,6 +205,13 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| if err != nil { |
| return err |
| } |
| + waiting := true |
| + if h.cfg.WaitingRoom != nil { |
| + waiting, err = h.cfg.WaitingRoom(ctx, sqid) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| h.mu.Lock() |
| defer h.mu.Unlock() |
| p, found := h.findLocked(sqid, session, peer) |
| @@ -230,7 +237,7 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| // Waiting. A guest waits on; a peer the tick promoted to member |
| // while the room was full may retry here, which is the retry |
| // §2.4 promises them. |
| - if kind != KindMember { |
| + if kind != KindMember && waiting { |
| return nil |
| } |
| if !h.admitLocked(r, p) { |
| @@ -239,7 +246,7 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| } |
| return nil |
| } |
| - if kind == KindMember { |
| + if kind == KindMember || !waiting { |
| if !h.admitLocked(r, p) { |
| h.sendLocked(p, Message{Type: TypeFull}) |
| return ErrMeshFull |
| @@ -471,3 +478,29 @@ func (h *Hub) End(sqid string) { |
| // §4.5's terminal 410 "ended" into a 404, which sends a browser off |
| // to open a fresh stream on a meeting that is over. |
| } |
| + |
| +// RefreshWaiting rechecks queued guests through the normal entry gate after |
| +// meeting settings change. Database callbacks must run outside the hub lock. |
| +func (h *Hub) RefreshWaiting(ctx context.Context, sqid string) error { |
| + type waitingPeer struct { |
| + session string |
| + peer PeerID |
| + } |
| + h.mu.Lock() |
| + var peers []waitingPeer |
| + if room := h.rooms[sqid]; room != nil { |
| + for _, p := range room.peers { |
| + if p.Joined && !p.Admitted { |
| + peers = append(peers, waitingPeer{p.Session, p.Peer}) |
| + } |
| + } |
| + } |
| + h.mu.Unlock() |
| + for _, p := range peers { |
| + err := h.Join(ctx, sqid, p.session, p.peer) |
| + if err != nil && err != ErrMeshFull && err != ErrUnknownPeer && err != ErrNotYours { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go| index 1d0ed84..22c579c 100644 |
| --- a/internal/calls/hub/hub.go |
| +++ b/internal/calls/hub/hub.go |
| @@ -64,8 +64,11 @@ var ( |
| ) |
| type Config struct { |
| - Now func() time.Time |
| - Logger *slog.Logger |
| + // WaitingRoom is read at join time, outside the lock. Nil preserves the |
| + // hub’s original behavior: guests wait for admission. |
| + WaitingRoom func(context.Context, string) (bool, error) |
| + Now func() time.Time |
| + Logger *slog.Logger |
| // StandingFor re-derives a person's kind and whether they still hold |
| // entry authority for the room. ok=false means standing none. |
| StandingFor func(ctx context.Context, room string, personID int64) (kind Kind, ok bool, err error) |
diff --git a/internal/calls/hub/waiting_room_test.go b/internal/calls/hub/waiting_room_test.go| new file mode 100644 |
| index 0000000..ab389cf |
| --- /dev/null |
| +++ b/internal/calls/hub/waiting_room_test.go |
| @@ -0,0 +1,59 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestDirectAdmissionKeepsGuestsAndEnforcesCapacity(t *testing.T) { |
| + now := time.Now() |
| + h := testHub(&now) |
| + h.cfg.WaitingRoom = func(context.Context, string) (bool, error) { return false, nil } |
| + for i := 0; i < MaxAdmitted; i++ { |
| + session := fmt.Sprintf("guest-%d", i) |
| + o, err := h.Open("r", session, int64(100+i), "Guest", KindGuest) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Join(testCtx, "r", session, o.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, _ := h.Find("r", session, o.Peer) |
| + if !p.Admitted || p.Kind != KindGuest { |
| + t.Fatal("direct admission changed authority", p) |
| + } |
| + } |
| + extra, _ := h.Open("r", "extra", 200, "Extra", KindGuest) |
| + if err := h.Join(testCtx, "r", "extra", extra.Peer); err != ErrMeshFull { |
| + t.Fatal("capacity bypassed", err) |
| + } |
| + p, _ := h.Find("r", "extra", extra.Peer) |
| + if p.Admitted { |
| + t.Fatal("overflow admitted") |
| + } |
| +} |
| + |
| +func TestDisablingWaitingRoomAdmitsQueuedGuests(t *testing.T) { |
| + now := time.Now() |
| + h := testHub(&now) |
| + waiting := true |
| + h.cfg.WaitingRoom = func(context.Context, string) (bool, error) { return waiting, nil } |
| + o, _ := h.Open("r", "guest", 100, "Guest", KindGuest) |
| + if err := h.Join(testCtx, "r", "guest", o.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, _ := h.Find("r", "guest", o.Peer) |
| + if p.Admitted || !p.Joined { |
| + t.Fatal("did not wait") |
| + } |
| + waiting = false |
| + if err := h.RefreshWaiting(testCtx, "r"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, _ = h.Find("r", "guest", o.Peer) |
| + if !p.Admitted || p.Kind != KindGuest { |
| + t.Fatal("retry not admitted as guest") |
| + } |
| +} |
diff --git a/internal/calls/migrations/0004_alter_persons.sql b/internal/calls/migrations/0004_alter_persons.sql| new file mode 100644 |
| index 0000000..e5e09d4 |
| --- /dev/null |
| +++ b/internal/calls/migrations/0004_alter_persons.sql |
| @@ -0,0 +1,11 @@ |
| +ALTER TABLE `persons` ADD `anonymous` numeric NOT NULL DEFAULT false; |
| + |
| +ALTER TABLE `meetings` ADD `require_signin` numeric NOT NULL DEFAULT false; |
| + |
| +ALTER TABLE `meetings` ADD `waiting_room` numeric NOT NULL DEFAULT false; |
| + |
| +CREATE TABLE `meeting_defaults` (`id` integer PRIMARY KEY AUTOINCREMENT,`require_signin` numeric NOT NULL DEFAULT false,`waiting_room` numeric NOT NULL DEFAULT false); |
| + |
| + |
| +-- Existing invitations retain their original sign-in and admission requirements. |
| +UPDATE meetings SET require_signin = true, waiting_room = true; |
diff --git a/internal/calls/migrations/schema.sql b/internal/calls/migrations/schema.sql| index 33efceb..9147b74 100644 |
| --- a/internal/calls/migrations/schema.sql |
| +++ b/internal/calls/migrations/schema.sql |
| @@ -5,13 +5,15 @@ CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NU |
| CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text); |
| +CREATE TABLE `meeting_defaults` (`id` integer PRIMARY KEY AUTOINCREMENT,`require_signin` numeric NOT NULL DEFAULT false,`waiting_room` numeric NOT NULL DEFAULT false); |
| + |
| 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); |
| +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 `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); |
| -CREATE TABLE `persons` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text NOT NULL,`name` text NOT NULL,`created_at` datetime); |
| +CREATE TABLE `persons` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text NOT NULL,`name` text NOT NULL,`created_at` datetime, `anonymous` numeric NOT NULL DEFAULT false); |
| CREATE INDEX `idx_agents_operator_person_id` ON `agents`(`operator_person_id`); |
diff --git a/internal/calls/models.go b/internal/calls/models.go| index 56aa622..6a50f82 100644 |
| --- a/internal/calls/models.go |
| +++ b/internal/calls/models.go |
| @@ -13,11 +13,14 @@ var Models = []any{ |
| &GuestGrant{}, |
| &PendingJoin{}, |
| &Agent{}, |
| + &MeetingDefaults{}, |
| } |
| -// Person is a signed-in address. Standing (member, guest, none) is |
| -// never a column; it is computed per request (spec §2.3). |
| +// Person is a verified identity or an anonymous guest. Anonymous guests carry |
| +// an opaque guest: identifier in Email, never a claimed or verified address. |
| +// Standing (member, guest, none) is computed per request, never stored. |
| type Person struct { |
| + Anonymous bool `gorm:"not null;default:false"` |
| ID int64 |
| Email string `gorm:"uniqueIndex;not null"` |
| Name string `gorm:"not null"` |
| @@ -33,14 +36,16 @@ 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. |
| type Meeting struct { |
| - ID int64 |
| - Sqid string `gorm:"uniqueIndex;not null"` |
| - TitleSealed []byte `gorm:"column:title_sealed"` |
| - StartsAt *time.Time |
| - CreatedBy string `gorm:"not null"` |
| - CreatedAt time.Time |
| - EndedAt *time.Time |
| - EndedBy string |
| + RequireSignin bool `gorm:"not null;default:false"` |
| + WaitingRoom bool `gorm:"not null;default:false"` |
| + ID int64 |
| + Sqid string `gorm:"uniqueIndex;not null"` |
| + TitleSealed []byte `gorm:"column:title_sealed"` |
| + StartsAt *time.Time |
| + CreatedBy string `gorm:"not null"` |
| + CreatedAt time.Time |
| + EndedAt *time.Time |
| + EndedBy string |
| } |
| // MeetingLink looks up invitations by hash. New tokens are also sealed so |
| @@ -93,3 +98,10 @@ type Agent struct { |
| CreatedAt time.Time |
| RevokedAt *time.Time |
| } |
| + |
| +// MeetingDefaults holds the team’s defaults for newly created meetings. |
| +type MeetingDefaults struct { |
| + ID int64 |
| + RequireSignin bool `gorm:"not null;default:false"` |
| + WaitingRoom bool `gorm:"not null;default:false"` |
| +} |
diff --git a/internal/calls/native_auth.go b/internal/calls/native_auth.go| index 6cc80d5..03b76c7 100644 |
| --- a/internal/calls/native_auth.go |
| +++ b/internal/calls/native_auth.go |
| @@ -208,12 +208,24 @@ func (a *app) nativeAuthorize(w http.ResponseWriter, r *http.Request) { |
| n.mu.Lock() |
| p := n.find(id) |
| valid := p != nil && p.Cookie == "" |
| + cancelHref := "/" |
| + if valid { |
| + cancelHref = p.Target |
| + if strings.HasPrefix(cancelHref, "/m/") { |
| + cancelHref += "/join" |
| + } |
| + } |
| n.mu.Unlock() |
| if !valid { |
| http.NotFound(w, r) |
| return |
| } |
| - render(w, "native", a.page(r, map[string]any{"Title": "Sign in to the Calls app", "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)})) |
| + person, _ := currentPerson(r) |
| + title := "Sign in to the Calls app" |
| + if person.Anonymous { |
| + title = "Continue to the Calls app" |
| + } |
| + render(w, "native", a.page(r, map[string]any{"CancelHref": cancelHref, "Guest": person.Anonymous, "Title": title, "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)})) |
| } |
| func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) { |
| nativeHeaders(w) |
| @@ -237,5 +249,10 @@ func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) { |
| func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) { |
| nativeHeaders(w) |
| - render(w, "native", a.page(r, map[string]any{"Title": "Calls is signed in", "Done": true})) |
| + person, _ := currentPerson(r) |
| + title := "Calls is signed in" |
| + if person.Anonymous { |
| + title = "Calls is ready" |
| + } |
| + render(w, "native", a.page(r, map[string]any{"Title": title, "Done": true})) |
| } |
diff --git a/internal/calls/native_auth_test.go b/internal/calls/native_auth_test.go| index 9f24f0c..e5bdef1 100644 |
| --- a/internal/calls/native_auth_test.go |
| +++ b/internal/calls/native_auth_test.go |
| @@ -138,3 +138,63 @@ func TestNativeHandoffResumesAfterHomeAssertion(t *testing.T) { |
| t.Fatal("Home lost native continuation", got) |
| } |
| } |
| + |
| +func TestNativeHandoffPreservesAnonymousGuest(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()) |
| + } |
| + id, v := nativeBegin(t, f) |
| + if w := f.do("GET", "/native/authorize?id="+id, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Continue as guest") { |
| + t.Fatal(w.Code) |
| + } |
| + if w := f.do("POST", "/native/authorize?id="+id, ""); w.Code != 303 { |
| + t.Fatal(w.Code) |
| + } |
| + w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}) |
| + if w.Code != 200 { |
| + t.Fatal(w.Code, w.Body.String()) |
| + } |
| + var result map[string]string |
| + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.cookies = map[string]*http.Cookie{result["name"]: {Name: result["name"], Value: result["value"]}} |
| + if w := f.do("GET", "/m/"+m.Sqid+"/join", ""); w.Code != 200 { |
| + t.Fatal("native guest cannot join", w.Code) |
| + } |
| + if w := f.do("GET", "/settings", ""); w.Code != 404 { |
| + t.Fatal("native guest gained member authority", w.Code) |
| + } |
| +} |
| + |
| +func TestNativeGuestInvitationResumesAuthorizationWithoutSignin(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + m, token, err := f.app.store.createMeeting(t.Context(), "Native guest call", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + verifier := nativeRandom() |
| + challenge := sha256.Sum256([]byte(verifier)) |
| + w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(challenge[:]), "target": "/j/" + token}) |
| + var request map[string]string |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &request) != nil { |
| + t.Fatal(w.Code) |
| + } |
| + if dest := homeRedirect(t, f.do("GET", "/native/start?id="+request["id"], "")); dest != "/j/"+token { |
| + t.Fatal(dest) |
| + } |
| + if w := f.do("GET", "/j/"+token, ""); w.Code != 200 { |
| + t.Fatal("guest invitation required sign-in", w.Code) |
| + } |
| + if dest := homeRedirect(t, f.do("POST", "/j/"+token+"/guest", "name=Visitor")); dest != "/m/"+m.Sqid+"/join" { |
| + t.Fatal(dest) |
| + } |
| + if dest := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/join", "")); dest != "/native/authorize?id="+request["id"] { |
| + t.Fatal("guest lost native handoff", dest) |
| + } |
| +} |
diff --git a/internal/calls/render.go b/internal/calls/render.go| index 9039de6..941e385 100644 |
| --- a/internal/calls/render.go |
| +++ b/internal/calls/render.go |
| @@ -44,7 +44,7 @@ func callAssets() fs.FS { |
| return files |
| } |
| -var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native", "share", "credits"} |
| +var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native", "share", "credits", "guest", "meeting_settings"} |
| var pages = map[string]*template.Template{} |
| @@ -108,7 +108,7 @@ type Viewer struct { |
| // a.isMember — the team as it stands right now (standing.go) — which |
| // a bare *http.Request cannot answer on its own. |
| func (a *app) page(r *http.Request, data map[string]any) map[string]any { |
| - if p, ok := currentPerson(r); ok { |
| + if p, ok := currentPerson(r); ok && !p.Anonymous { |
| data["Viewer"] = Viewer{Email: p.Email, Member: a.isMember(p)} |
| } |
| return data |
diff --git a/internal/calls/standing.go b/internal/calls/standing.go| index 09638dd..21f43c9 100644 |
| --- a/internal/calls/standing.go |
| +++ b/internal/calls/standing.go |
| @@ -83,10 +83,13 @@ func (a *app) allowNow() allowlist { |
| // isMember is the lone-shape roster read: the allowlist. |
| func (a *app) isMember(p Person) bool { |
| l := a.allowNow() |
| - return l.addrs != nil && l.allows(p.Email) |
| + return !p.Anonymous && l.addrs != nil && l.allows(p.Email) |
| } |
| func (a *app) standingFor(ctx context.Context, p Person, m Meeting) (Standing, error) { |
| + if p.Anonymous && m.RequireSignin { |
| + return StandingNone, nil |
| + } |
| if a.isMember(p) { |
| return StandingMember, nil |
| } |
diff --git a/internal/calls/store.go b/internal/calls/store.go| index 89cebd6..8a9732d 100644 |
| --- a/internal/calls/store.go |
| +++ b/internal/calls/store.go |
| @@ -115,6 +115,10 @@ func (s *store) createMeeting(ctx context.Context, title string, startsAt *time. |
| if err != nil { |
| return Meeting{}, "", err |
| } |
| + defaults, err := s.meetingDefaults(ctx) |
| + if err != nil { |
| + return Meeting{}, "", err |
| + } |
| var m Meeting |
| err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| for attempt := 0; ; attempt++ { |
| @@ -122,7 +126,7 @@ func (s *store) createMeeting(ctx context.Context, title string, startsAt *time. |
| if err != nil { |
| return err |
| } |
| - m = Meeting{Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()} |
| + m = Meeting{RequireSignin: defaults.RequireSignin, WaitingRoom: defaults.WaitingRoom, 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 |
| @@ -639,3 +643,25 @@ func (s *store) homePersonByID(ctx context.Context, q interface { |
| err := q.QueryRowContext(ctx, "SELECT id, email, name FROM persons WHERE id = ?", id).Scan(&p.ID, &p.Email, &p.Name) |
| return p, err |
| } |
| + |
| +func (s *store) meetingDefaults(ctx context.Context) (MeetingDefaults, error) { |
| + var d MeetingDefaults |
| + err := s.g.WithContext(ctx).Where("id = ?", 1).Take(&d).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return MeetingDefaults{ID: 1}, nil |
| + } |
| + return d, err |
| +} |
| +func (s *store) saveMeetingDefaults(ctx context.Context, signin, waiting bool) error { |
| + return s.g.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"require_signin", "waiting_room"})}).Create(&MeetingDefaults{ID: 1, RequireSignin: signin, WaitingRoom: waiting}).Error |
| +} |
| + |
| +// Meetings are team-owned, as are the other meeting mutations in this store. |
| +func (s *store) saveMeetingSettings(ctx context.Context, id int64, signin, waiting bool) error { |
| + return s.g.WithContext(ctx).Model(&Meeting{}).Where("id = ? AND ended_at IS NULL", id).Select("RequireSignin", "WaitingRoom").Updates(map[string]any{"RequireSignin": signin, "WaitingRoom": waiting}).Error |
| +} |
| +func (s *store) createGuest(ctx context.Context, name string) (Person, error) { |
| + p := Person{Email: "guest:" + nativeRandom(), Name: name, Anonymous: true, CreatedAt: s.now().UTC()} |
| + err := s.g.WithContext(ctx).Create(&p).Error |
| + return p, err |
| +} |
diff --git a/internal/calls/templates/guest.html b/internal/calls/templates/guest.html| new file mode 100644 |
| index 0000000..1fa99ae |
| --- /dev/null |
| +++ b/internal/calls/templates/guest.html |
| @@ -0,0 +1,11 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" .Title}} |
| +<p><a href="{{.NativeURL}}" rst-btn>Join in the app</a></p> |
| +<p>If the app isn’t installed, you can join in your browser below.</p> |
| +<form rst-form method="post" action="/j/{{.Token}}/guest"> |
| +{{template "field" dict "ID" "name" "Name" "name" "Label" "Your name" "Required" true "Maxlength" "80" "Value" .Name "Error" .NameError}} |
| +<div rst-form-actions><button type="submit" rst-btn="primary lg">Continue in browser</button></div> |
| +</form> |
| +<p>No account needed. You can also <a href="/signin">sign in</a>.</p> |
| +{{end}} |
diff --git a/internal/calls/templates/index.html b/internal/calls/templates/index.html| index 85dfb4c..d6259d4 100644 |
| --- a/internal/calls/templates/index.html |
| +++ b/internal/calls/templates/index.html |
| @@ -22,7 +22,7 @@ |
| <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a>{{if .ByAgent}} {{icon "bot"}}{{template "badge" dict "Label" "Agent"}}{{end}}</h3> |
| {{if .ByAgent}}<p>by {{.CreatedByPrincipal}}</p>{{end}} |
| <p>{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · no live link{{end}}</p> |
| - <p><a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p> |
| + <p><a href="/meetings/{{.Sqid}}/settings">Settings</a> · <a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p> |
| </article> |
| {{end}} |
| </div> |
diff --git a/internal/calls/templates/join.html b/internal/calls/templates/join.html| index a4f8ee2..d87afc2 100644 |
| --- a/internal/calls/templates/join.html |
| +++ b/internal/calls/templates/join.html |
| @@ -6,6 +6,8 @@ |
| {{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}} |
| <div class="calls-lobby-heading">{{template "page-header" dict "Title" .Title}}</div> |
| {{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| +<p><a href="{{.NativeURL}}" rst-btn>Join in the app</a></p> |
| +<p>Or use your browser below. The app must be installed to open it.</p> |
| <p>You are {{.Name}}, joining as a {{.Standing}}.</p> |
| {{if .Floor}}<p rst-notice>This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false" estilo-call-actions><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}} |
| @@ -31,11 +33,11 @@ |
| <div class="calls-controls" estilo-call-actions> |
| <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="calls-toggle-state" aria-hidden="true">On</span></button> |
| <button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="toggle-camera-state" class="calls-toggle-state" aria-hidden="true">On</span></button> |
| - <button type="button" id="join" rst-btn="primary lg" disabled>Join the call</button> |
| + <button type="button" id="join" rst-btn="primary lg" disabled>Join in browser</button> |
| </div> |
| <p id="status" class="calls-status" role="status"></p> |
| </div> |
| </div> |
| </div> |
| -{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}} |
| +{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/settings">Meeting settings</a></p><p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}} |
| {{end}} |
diff --git a/internal/calls/templates/meeting_settings.html b/internal/calls/templates/meeting_settings.html| new file mode 100644 |
| index 0000000..6dadd24 |
| --- /dev/null |
| +++ b/internal/calls/templates/meeting_settings.html |
| @@ -0,0 +1,12 @@ |
| +{{define "title"}}Meeting settings{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" (printf "/m/%s/join" .Meeting.Sqid) "Label" "Meeting"}} |
| +{{template "page-header" dict "Title" "Meeting settings"}} |
| +<p>{{.Title}}</p> |
| +{{if .Saved}}<p rst-notice role="status">Meeting settings saved.</p>{{end}} |
| +<p>Changes apply when people join. People already admitted can stay in the call.</p> |
| +<form rst-form method="post" action="/meetings/{{.Meeting.Sqid}}/settings"> |
| +{{template "meeting-options" .Meeting}} |
| +<div rst-form-actions><button type="submit" rst-btn="primary lg">Save settings</button></div> |
| +</form> |
| +{{end}} |
diff --git a/internal/calls/templates/native.html b/internal/calls/templates/native.html| index f741e40..a35e03f 100644 |
| --- a/internal/calls/templates/native.html |
| +++ b/internal/calls/templates/native.html |
| @@ -6,8 +6,13 @@ |
| <p>Return to the Calls app to join your meeting. You can close this tab.</p> |
| {{else}} |
| <p>Check that this code matches the one in your Calls app: <strong class="rst-mono">{{.Code}}</strong></p> |
| +{{if .Guest}} |
| +<p>Continue only if you just opened the Calls app on your own device. This lets the app join this meeting as your guest identity.</p> |
| +{{template "confirm-form" dict "Action" .Action "Label" "Continue as guest" "CancelHref" .CancelHref "CancelLabel" "Cancel"}} |
| +{{else}} |
| <p>Continue only if you just started signing in from the Calls app on your own device. This gives that app access to Calls as the account shown above.</p> |
| {{template "confirm-form" dict "Action" .Action "Label" "Sign in to Calls app" "CancelHref" "/" "CancelLabel" "Cancel"}} |
| {{end}} |
| +{{end}} |
| </div> |
| {{end}} |
diff --git a/internal/calls/templates/partials/meeting-options.html b/internal/calls/templates/partials/meeting-options.html| new file mode 100644 |
| index 0000000..95b456e |
| --- /dev/null |
| +++ b/internal/calls/templates/partials/meeting-options.html |
| @@ -0,0 +1,6 @@ |
| +{{define "meeting-options"}} |
| +{{template "field-check" dict "Name" "require_signin" "Label" "Require sign-in to join" "Checked" .RequireSignin}} |
| +<p>Off: anyone with the invitation link can join using their name.</p> |
| +{{template "field-check" dict "Name" "waiting_room" "Label" "Enable waiting room" "Checked" .WaitingRoom}} |
| +<p>On: guests wait for a team member to admit them. Off: guests join directly.</p> |
| +{{end}} |
diff --git a/internal/calls/templates/settings.html b/internal/calls/templates/settings.html| index 91bd11c..e0a7cb2 100644 |
| --- a/internal/calls/templates/settings.html |
| +++ b/internal/calls/templates/settings.html |
| @@ -2,6 +2,15 @@ |
| {{define "content"}} |
| {{template "page-header" dict "Title" "Settings"}} |
| <section> |
| +<h2>Meeting defaults</h2> |
| +<p>Applies to new meetings created by anyone on this team. Existing meetings keep their own settings.</p> |
| +{{if .Saved}}<p rst-notice role="status">Meeting defaults saved.</p>{{end}} |
| +<form rst-form method="post" action="/settings/defaults"> |
| +{{template "meeting-options" .Defaults}} |
| +<div rst-form-actions><button type="submit" rst-btn="primary lg">Save defaults</button></div> |
| +</form> |
| +</section> |
| +<section> |
| <h2>Agents</h2> |
| {{if .Agents}} |
| <div rst-list> |
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go| index 6a56f52..f3e6777 100644 |
| --- a/internal/callstest/browser_test.go |
| +++ b/internal/callstest/browser_test.go |
| @@ -80,6 +80,7 @@ const ( |
| // and is skipped from inside its own subtest with t.Skip carrying the |
| // exact failure — never deleted, and never quietly loosened. |
| var scenarios = []string{ |
| + "guestdirect", |
| "layout", |
| "devices", |
| "call", |
diff --git a/internal/callstest/chat_test.go b/internal/callstest/chat_test.go| index f512d4a..d3ee735 100644 |
| --- a/internal/callstest/chat_test.go |
| +++ b/internal/callstest/chat_test.go |
| @@ -84,6 +84,7 @@ func TestChatReachesTheOtherAdmittedPeerWithTheSendersNameAndNotTheSender(t *tes |
| // stream, join — are inlined here instead of reusing that helper. |
| func TestChatNeverReachesAWaitingGuestAndAWaitingGuestCannotSend(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| a := x.browser(t) |
| a.signIn("paul@example.com") |
| sa := a.stream("/m/" + sqid + "/stream") |
diff --git a/internal/callstest/ice_test.go b/internal/callstest/ice_test.go| index b9b0380..ccb11be 100644 |
| --- a/internal/callstest/ice_test.go |
| +++ b/internal/callstest/ice_test.go |
| @@ -41,6 +41,7 @@ type iceAnswer struct { |
| // isolation_test.go's openIsolationRoom also builds inline, lifted here |
| // for a single guest. |
| func guestJoinedAndWaiting(t *testing.T, x *harness, sqid, tok string) (*client, string) { |
| + enableWaitingRoom(t, x, sqid) |
| t.Helper() |
| g := x.browser(t) |
| g.get("/j/" + tok) |
| @@ -574,6 +575,7 @@ func TestIceHeaderClampsExtremeValues(t *testing.T) { |
| // store read stands between that stale hub state and a 200. |
| func TestIceRefusesAnEndedMeetingEvenForAStillAdmittedGuest(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/callstest/iphone_browser_test.go b/internal/callstest/iphone_browser_test.go| index 24a22cd..908bc36 100644 |
| --- a/internal/callstest/iphone_browser_test.go |
| +++ b/internal/callstest/iphone_browser_test.go |
| @@ -51,7 +51,7 @@ func TestIPhoneSafariAuthentication(t *testing.T) { |
| ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) |
| defer cancel() |
| results := filepath.Join(root, "native/build/iphone-auth-"+name+"-"+time.Now().Format("150405")+".xcresult") |
| - cmd := exec.CommandContext(ctx, "xcodebuild", "-project", filepath.Join(root, "native/ios/MeetPhone.xcodeproj"), "-scheme", "MeetPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", filepath.Join(root, "native/build/ios-simulator"), "-resultBundlePath", results, "CODE_SIGNING_ALLOWED=NO", "-only-testing:MeetPhoneUITests/MeetPhoneUITests/testEmailBrowserHandoff", "test") |
| + cmd := exec.CommandContext(ctx, "xcodebuild", "-project", filepath.Join(root, "native/ios/CallsPhone.xcodeproj"), "-scheme", "CallsPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", filepath.Join(root, "native/build/ios-simulator"), "-resultBundlePath", results, "CODE_SIGNING_ALLOWED=NO", "-only-testing:CallsPhoneUITests/CallsPhoneUITests/testEmailBrowserHandoff", "test") |
| cmd.Env = append(os.Environ(), "TEST_RUNNER_CALLS_TEST_MEETING="+rig.origin+target, "TEST_RUNNER_CALLS_TEST_CONTROL="+control.URL, "TEST_RUNNER_CALLS_TEST_EMAIL="+memberB) |
| // Xcode's detailed trace contains only synthetic test identities and is |
| // retained locally for diagnosis, rather than flooding the normal gate log. |
| @@ -85,7 +85,7 @@ func TestIPhoneNativeMedia(t *testing.T) { |
| root := repoRootForBrowser(t) |
| ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) |
| defer cancel() |
| - cmd := exec.CommandContext(ctx, "xcodebuild", "-project", filepath.Join(root, "native/ios/MeetPhone.xcodeproj"), "-scheme", "MeetPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", filepath.Join(root, "native/build/ios-simulator"), "CODE_SIGNING_ALLOWED=NO", "-only-testing:MeetPhoneTests/MeetPhoneTests/testNativeVideoAndRenewal", "test") |
| + cmd := exec.CommandContext(ctx, "xcodebuild", "-project", filepath.Join(root, "native/ios/CallsPhone.xcodeproj"), "-scheme", "CallsPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", filepath.Join(root, "native/build/ios-simulator"), "CODE_SIGNING_ALLOWED=NO", "-only-testing:CallsPhoneTests/CallsPhoneTests/testNativeVideoAndRenewal", "test") |
| cmd.Env = append(os.Environ(), "TEST_RUNNER_CALLS_TEST_MEDIA="+string(fixture)) |
| logPath := filepath.Join(root, "native/build/iphone-media.log") |
| log, err := os.Create(logPath) |
diff --git a/internal/callstest/isolation_test.go b/internal/callstest/isolation_test.go| index 698ea1f..f10d016 100644 |
| --- a/internal/callstest/isolation_test.go |
| +++ b/internal/callstest/isolation_test.go |
| @@ -45,6 +45,7 @@ type isolationRoom struct { |
| func openIsolationRoom(t *testing.T) *isolationRoom { |
| t.Helper() |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| m := x.browser(t) |
| m.signIn("paul@example.com") |
diff --git a/internal/callstest/join_test.go b/internal/callstest/join_test.go| index dfd8385..8fc8822 100644 |
| --- a/internal/callstest/join_test.go |
| +++ b/internal/callstest/join_test.go |
| @@ -56,6 +56,9 @@ func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) { |
| func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + member := x.browser(t) |
| + member.signIn("paul@example.com") |
| + member.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}}) |
| g := x.browser(t) |
| rec := g.get("/j/" + tok) |
| if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" { |
diff --git a/internal/callstest/meeting_settings_test.go b/internal/callstest/meeting_settings_test.go| new file mode 100644 |
| index 0000000..66fe97f |
| --- /dev/null |
| +++ b/internal/callstest/meeting_settings_test.go |
| @@ -0,0 +1,132 @@ |
| +package callstest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func enableWaitingRoom(t *testing.T, x *harness, sqid string) { |
| + t.Helper() |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + if r := c.post("/meetings/"+sqid+"/settings", url.Values{"waiting_room": {"on"}}); r.Code != 303 { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| +} |
| + |
| +func TestMeetingDefaultsAndIndependentOverrides(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + old, _ := createMeeting(t, c, "Original", "") |
| + if r := c.post("/settings/defaults", url.Values{"require_signin": {"on"}, "waiting_room": {"on"}}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + next, token := createMeeting(t, c, "Restricted", "") |
| + if r := x.browser(t).get("/j/" + token); r.Code != 303 || r.Header().Get("Location") != "/signin" { |
| + t.Fatal("sign-in not required", r.Code) |
| + } |
| + for _, tc := range []struct { |
| + sqid string |
| + checked bool |
| + }{{old, false}, {next, true}} { |
| + r := c.get("/meetings/" + tc.sqid + "/settings") |
| + if r.Code != 200 || strings.Contains(r.Body.String(), " checked") != tc.checked { |
| + t.Fatal("settings not retained", r.Body.String()) |
| + } |
| + } |
| + if r := c.post("/meetings/"+next+"/settings", url.Values{}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + if r := x.browser(t).get("/j/" + token); r.Code != 200 { |
| + t.Fatal("guest override not applied", r.Code) |
| + } |
| + if r := c.get("/settings"); !strings.Contains(r.Body.String(), " checked") { |
| + t.Fatal("override altered defaults") |
| + } |
| +} |
| + |
| +func TestAnonymousGuestJoinsWithoutMemberPowers(t *testing.T) { |
| + x, sqid, token := memberWithMeeting(t) |
| + g := x.browser(t) |
| + r := g.get("/j/" + token) |
| + if r.Code != 200 || !strings.Contains(r.Body.String(), "oficina-meet://join?") { |
| + t.Fatal("missing guest/app choice", r.Code) |
| + } |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {" "}}); r.Code != 422 { |
| + t.Fatal("empty name accepted") |
| + } |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); r.Code != 303 { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + if r := g.get("/m/" + sqid + "/join"); r.Code != 200 || !strings.Contains(r.Body.String(), "Visitor") { |
| + t.Fatal("guest holding", r.Code, r.Body.String()) |
| + } |
| + for _, path := range []string{"/settings", "/meetings/" + sqid + "/settings", "/"} { |
| + if r := g.get(path); r.Code != 404 { |
| + t.Fatal("guest can reach", path, r.Code) |
| + } |
| + } |
| + if r := g.post("/meetings/"+sqid+"/settings", url.Values{"waiting_room": {"on"}}); r.Code != 404 { |
| + t.Fatal("guest can change settings") |
| + } |
| + s := g.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if res, b := g.signal(sqid, peer, `{"type":"join"}`); res.StatusCode != http.StatusOK { |
| + t.Fatal(res.StatusCode, string(b)) |
| + } |
| + if _, data, ok := s.nextOf("roster", 2*time.Second); !ok || !strings.Contains(string(data), `"kind":"guest"`) { |
| + t.Fatal("guest not directly admitted", string(data)) |
| + } |
| +} |
| + |
| +func TestAnonymousEntryRefusesRestrictedAndRevokedInvitations(t *testing.T) { |
| + x, sqid, token := memberWithMeeting(t) |
| + m := x.browser(t) |
| + m.signIn("paul@example.com") |
| + g := x.browser(t) |
| + m.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}}) |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); r.Code != 303 || r.Header().Get("Location") != "/signin" { |
| + t.Fatal("restricted guest POST accepted") |
| + } |
| + m.post("/meetings/"+sqid+"/settings", url.Values{}) |
| + g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}) |
| + m.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}}) |
| + if r := g.get("/m/" + sqid + "/join"); r.Code != 404 { |
| + t.Fatal("guest bypassed new sign-in requirement") |
| + } |
| + m.post("/meetings/"+sqid+"/links", nil) |
| + if r := x.browser(t).post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); r.Code != 404 { |
| + t.Fatal("revoked invitation accepted") |
| + } |
| +} |
| + |
| +func TestTurningOffWaitingRoomAdmitsQueuedAnonymousGuest(t *testing.T) { |
| + x, sqid, token := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| + g := x.browser(t) |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {"Waiting visitor"}}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + s := g.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if r, b := g.signal(sqid, peer, `{"type":"join"}`); r.StatusCode != 200 { |
| + t.Fatal(r.StatusCode, string(b)) |
| + } |
| + if r := g.getStream("/m/" + sqid + "/ice?peer=" + peer); r.Code != 404 { |
| + t.Fatal("waiting guest admitted prematurely", r.Code) |
| + } |
| + member := x.browser(t) |
| + member.signIn("paul@example.com") |
| + if r := member.post("/meetings/"+sqid+"/settings", url.Values{}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + if _, _, ok := s.nextOf("roster", 2*time.Second); !ok { |
| + t.Fatal("queued guest not admitted after disabling waiting room") |
| + } |
| +} |
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go| index 36907b1..2f4d206 100644 |
| --- a/internal/callstest/native_browser_test.go |
| +++ b/internal/callstest/native_browser_test.go |
| @@ -61,7 +61,7 @@ func nativeMacCallsBrowser(t *testing.T, guest, chat bool) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - binary := filepath.Join(root, "native/apple/.build/debug/MeetInterop") |
| + binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| if _, err := os.Stat(binary); err != nil { |
| t.Fatal("build native interop executable with make native-mac first") |
| } |
diff --git a/internal/callstest/signal_test.go b/internal/callstest/signal_test.go| index f2e658a..66d1a9b 100644 |
| --- a/internal/callstest/signal_test.go |
| +++ b/internal/callstest/signal_test.go |
| @@ -449,6 +449,7 @@ func TestSignalStaleGenIsRefusedWith409(t *testing.T) { |
| // guest waits, a member in the same room sees knock. |
| func TestSignalGuestJoinWaitsAndKnocksTheRoom(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| member := x.browser(t) |
| member.signIn("paul@example.com") |
| ms := member.stream("/m/" + sqid + "/stream") |
| @@ -580,6 +581,7 @@ func TestSignallingLogsCarryNoContent(t *testing.T) { |
| c := x.browser(t) |
| c.signIn("paul@example.com") |
| sqid, tok := createMeeting(t, c, "Secret title", "") |
| + enableWaitingRoom(t, x, sqid) |
| s := c.stream("/m/" + sqid + "/stream") |
| defer s.close() |
| peer, _ := s.hello() |
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift| index 2046256..182a696 100644 |
| --- a/native/apple/Sources/CallsKit/CallsLobby.swift |
| +++ b/native/apple/Sources/CallsKit/CallsLobby.swift |
| @@ -21,7 +21,7 @@ import SwiftUI |
| } |
| VStack(alignment: .leading, spacing: 20) { |
| if model.authenticating { |
| - ProgressView("Waiting for sign-in") |
| + ProgressView("Waiting for browser confirmation") |
| if !model.confirmationCode.isEmpty { |
| VStack(alignment: .leading, spacing: 10) { |
| Text(model.confirmationCode).font(.title.monospaced().bold()) |
| @@ -97,9 +97,9 @@ import SwiftUI |
| private var subheading: LocalizedStringKey { |
| if model.authenticating { |
| return |
| - "Open the emailed link in your browser, then confirm “Sign in to Calls app”. We’ll continue here automatically." |
| + "Continue in your browser, then confirm that the code matches. We’ll continue here automatically." |
| } |
| - if model.signedIn { return "You’re signed in. Join when you’re ready." } |
| + if model.signedIn { return "Join when you’re ready." } |
| return "A place for your team to come together." |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift| index 92a9a27..e5a7a62 100644 |
| --- a/native/apple/Sources/CallsKit/CallsModel.swift |
| +++ b/native/apple/Sources/CallsKit/CallsModel.swift |
| @@ -25,6 +25,33 @@ import Foundation |
| private var cookies = "" |
| public init() {} |
| + public func openMeetingURL(_ url: URL) { |
| + guard !joining else { |
| + error = "Leave the current meeting before opening another." |
| + return |
| + } |
| + guard url.scheme == "oficina-meet", url.host == "join", |
| + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| + let target = components.queryItems?.first(where: { $0.name == "url" })?.value, |
| + let meeting = URL(string: target), meeting.scheme == "https", |
| + meeting.host != nil, meeting.user == nil, meeting.password == nil, |
| + meeting.query == nil, meeting.fragment == nil |
| + else { |
| + error = "This meeting link is not valid." |
| + return |
| + } |
| + let path = meeting.path.split(separator: "/") |
| + guard path.count == 2, path[0] == "m" || path[0] == "j" else { |
| + error = "This meeting link is not valid." |
| + return |
| + } |
| + cancelSignIn() |
| + signedIn = false |
| + error = "" |
| + address = meeting.absoluteString |
| + // Let the person review the meeting address before starting authorization. |
| + } |
| + |
| public func connect() { |
| cancelSignIn() |
| authenticating = true |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index a70d59a..8252709 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -10,6 +10,7 @@ import WebRTC |
| var body: some Scene { |
| Window("Calls", id: "main") { |
| ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| + .onOpenURL { model.openMeetingURL($0) } |
| } |
| .defaultSize(width: 1000, height: 760) |
| .commands { CommandGroup(replacing: .newItem) {} } |
diff --git a/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift b/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift| new file mode 100644 |
| index 0000000..d3d2e07 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift |
| @@ -0,0 +1,28 @@ |
| +import XCTest |
| +@testable import CallsKit |
| + |
| +final class MeetingLinkTests: XCTestCase { |
| + @MainActor func testInvitationOpensInLobbyWithoutJoiningOrStartingAuthentication() { |
| + let model = CallsModel() |
| + model.openMeetingURL(URL(string: "oficina-meet://join?url=https%3A%2F%2Fmeet.example.com%2Fj%2Finvitation")!) |
| + XCTAssertEqual(model.address, "https://meet.example.com/j/invitation") |
| + XCTAssertFalse(model.joining) |
| + XCTAssertFalse(model.authenticating) |
| + } |
| + |
| + @MainActor func testRejectsUntrustedURLShapesAndDoesNotInterruptCall() { |
| + let model = CallsModel() |
| + for target in ["file:///j/token", "https://user:secret@meet.example.com/j/token", "https://meet.example.com/settings"] { |
| + var link = URLComponents(string: "oficina-meet://join")! |
| + link.queryItems = [URLQueryItem(name: "url", value: target)] |
| + model.openMeetingURL(link.url!) |
| + XCTAssertEqual(model.address, "") |
| + XCTAssertFalse(model.error.isEmpty) |
| + } |
| + model.joining = true |
| + model.address = "current" |
| + model.openMeetingURL(URL(string: "oficina-meet://join?url=https%3A%2F%2Fmeet.example.com%2Fm%2Froom")!) |
| + XCTAssertEqual(model.address, "current") |
| + XCTAssertTrue(model.joining) |
| + } |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index ea0a76c..cfc283b 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -8,6 +8,7 @@ import WebRTC |
| var body: some Scene { |
| WindowGroup { |
| PhoneHome(model: model) |
| + .onOpenURL { model.openMeetingURL($0) } |
| .onChange(of: scenePhase) { _, phase in |
| // This milestone is foreground-only. Browser sign-in is kept alive; |
| // an active call leaves cleanly instead of silently sending audio. |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 7d6e56d..35e85b9 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -21,6 +21,12 @@ targets: |
| - Assets.xcassets |
| - path: ../LICENSE.lucide |
| buildPhase: resources |
| + info: |
| + path: ../build/ios-info.plist |
| + properties: |
| + CFBundleURLTypes: |
| + - CFBundleURLName: net.amadan.oficina.meet.join |
| + CFBundleURLSchemes: [oficina-meet] |
| dependencies: |
| - package: CallsNative |
| product: CallsKit |
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py| index 792c307..f034c71 100644 |
| --- a/native/scripts/bundle-mac.py |
| +++ b/native/scripts/bundle-mac.py |
| @@ -5,13 +5,13 @@ import shutil |
| import subprocess |
| root = Path(__file__).resolve().parent.parent |
| -bundle = root / 'build/Meet.app/Contents' |
| +bundle = root / 'build/Calls.app/Contents' |
| (bundle / 'MacOS').mkdir(parents=True, exist_ok=True) |
| (bundle / 'Frameworks').mkdir(parents=True, exist_ok=True) |
| (bundle / 'Resources').mkdir(parents=True, exist_ok=True) |
| -shutil.copy2(root / 'apple/.build/debug/MeetMac', bundle / 'MacOS/MeetMac') |
| +shutil.copy2(root / 'apple/.build/debug/CallsMac', bundle / 'MacOS/CallsMac') |
| subprocess.run(['swift', str(root / 'scripts/generate-mac-icon.swift')], check=True) |
| -icon = root / 'apple/Sources/MeetMac/Resources/MacIcon.png' |
| +icon = root / 'apple/Sources/CallsMac/Resources/MacIcon.png' |
| shutil.copy2(icon, bundle / 'Resources/MacIcon.png') |
| shutil.copy2(root / 'LICENSE.lucide', bundle / 'Resources/LICENSE.lucide') |
| # Finder reads the ICNS before the app launches; the PNG also supplies the |
| @@ -25,21 +25,23 @@ for size in (16, 32, 128, 256, 512): |
| str(icon), '--out', str(iconset / name)], |
| check=True, stdout=subprocess.DEVNULL) |
| subprocess.run(['iconutil', '-c', 'icns', str(iconset), '-o', |
| - str(bundle / 'Resources/MeetMac.icns')], check=True) |
| + str(bundle / 'Resources/CallsMac.icns')], check=True) |
| info = { |
| - 'CFBundleName': 'Meet', 'CFBundleDisplayName': 'Meet', |
| + 'CFBundleURLTypes': [{'CFBundleURLName': 'net.amadan.oficina.meet.join', |
| + 'CFBundleURLSchemes': ['oficina-meet']}], |
| + 'CFBundleName': 'Calls', 'CFBundleDisplayName': 'Calls', |
| 'CFBundleIdentifier': 'net.amadan.oficina.calls', |
| - 'CFBundleExecutable': 'MeetMac', 'CFBundlePackageType': 'APPL', |
| - 'CFBundleIconFile': 'MeetMac.icns', |
| + 'CFBundleExecutable': 'CallsMac', 'CFBundlePackageType': 'APPL', |
| + 'CFBundleIconFile': 'CallsMac.icns', |
| 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', |
| 'LSMinimumSystemVersion': '14.0', 'NSHighResolutionCapable': True, |
| - 'NSCameraUsageDescription': 'Meet uses your camera to share video in a meeting.', |
| - 'NSMicrophoneUsageDescription': 'Meet uses your microphone so people in the meeting can hear you.', |
| + 'NSCameraUsageDescription': 'Calls uses your camera to share video in a meeting.', |
| + 'NSMicrophoneUsageDescription': 'Calls uses your microphone so people in the meeting can hear you.', |
| } |
| (bundle / 'Info.plist').write_bytes(plistlib.dumps(info)) |
| # SwiftPM links dynamic binary dependencies using @rpath. Embed just the macOS |
| # slice rather than shipping iOS and simulator frameworks inside the Mac app. |
| -for xc in [root / 'build/MeetBridge.xcframework', *list((root / 'apple/.build/artifacts').rglob('WebRTC.xcframework'))]: |
| +for xc in [root / 'build/CallsBridge.xcframework', *list((root / 'apple/.build/artifacts').rglob('WebRTC.xcframework'))]: |
| manifest = plistlib.loads((xc / 'Info.plist').read_bytes()) |
| for lib in manifest['AvailableLibraries']: |
| if lib['SupportedPlatform'] != 'macos': |
| @@ -51,4 +53,4 @@ for xc in [root / 'build/MeetBridge.xcframework', *list((root / 'apple/.build/ar |
| dest = bundle / 'Frameworks' / source.name |
| if dest.exists(): shutil.rmtree(dest) |
| shutil.copytree(source, dest, symlinks=True) |
| -subprocess.run(['install_name_tool', '-add_rpath', '@executable_path/../Frameworks', str(bundle / 'MacOS/MeetMac')], check=True) |
| +subprocess.run(['install_name_tool', '-add_rpath', '@executable_path/../Frameworks', str(bundle / 'MacOS/CallsMac')], check=True) |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 23f3f4c..08dab25 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -978,8 +978,14 @@ function leakedCredential(credentials, lines) { |
| // browser: follow the emailed link signed out, ask for a sign-in link, |
| // and click the one that was actually mailed. |
| async function signInAsGuest(ctx, address = GUEST) { |
| + const member = await newContext('configure waiting room', { cookie: cookieA }); |
| + await member.request.post(origin + '/meetings/' + sqid + '/settings', { |
| + headers: { Origin: origin }, form: { waiting_room: 'on' }, |
| + }); |
| + await member.close(); |
| const page = await ctx.newPage(); |
| await page.goto(origin + '/j/' + token, { waitUntil: 'domcontentloaded' }); |
| + if (page.url().includes('/j/')) await page.getByRole('link', { name: 'sign in', exact: true }).click(); |
| check(page.url().startsWith(origin + '/signin'), |
| 'the link sends a signed-out outsider to sign in (at ' + where(page) + ')'); |
| @@ -1133,6 +1139,28 @@ function shareControl(page) { |
| // ── the scenarios ──────────────────────────────────────────────────── |
| const run = { |
| + async guestdirect() { |
| + const member = await newContext('settings owner', { cookie: cookieA, javaScriptEnabled: false }); |
| + const settings = await member.newPage(); |
| + await settings.goto(origin + '/settings'); |
| + check(!(await settings.getByLabel('Require sign-in to join').isChecked()), 'new meeting defaults permit guests'); |
| + check(!(await settings.getByLabel('Enable waiting room').isChecked()), 'waiting room defaults off'); |
| + await settings.screenshot({ path: '/tmp/calls-settings.png', fullPage: true }); |
| + await member.close(); |
| + const guest = await newContext('anonymous guest'); |
| + const page = await guest.newPage(); |
| + await page.goto(origin + '/j/' + token); |
| + check((await page.getByRole('link', { name: 'Join in the app' }).getAttribute('href')).startsWith('oficina-meet://join?'), 'invitation offers the native app'); |
| + await page.getByLabel('Your name').fill('Visitor'); |
| + await Promise.all([ |
| + page.waitForURL('**/m/' + sqid + '/join'), |
| + page.getByRole('button', { name: 'Continue in browser' }).click(), |
| + ]); |
| + check(await page.getByRole('link', { name: 'Join in the app' }).count() === 1, 'holding screen offers the native app'); |
| + await page.locator('#join').click(); |
| + check(await until(async () => (await state(page)).admitted, 20000), 'anonymous guest joins directly without sign-in or admission'); |
| + await guest.close(); |
| + }, |
| // The holding screen reaches real hardware: it enumerates devices, |
| // previews one, and lets the person in. |
| async devices() { |