Merge branch 'codex/native-mac-milestone' into main
Add native Mac Meet with browser sign-in handoff
61 files changed,
+4541
−121
.gitignore+5 −0AGENTS.md+4 −1Makefile+15 −0cmd/meet/main.go+2 −0docs/superpowers/specs/2026-09-07-meet-design.md+33 −1go.mod+5 −1go.sum+8 −2internal/meet/app.go+45 −7internal/meet/auth.go+12 −3internal/meet/config.go+7 −0internal/meet/handlers_join.go+7 −1internal/meet/handlers_meetings.go+1 −1internal/meet/handlers_room.go+10 −0internal/meet/home.go+123 −0internal/meet/home_native_test.go+121 −0internal/meet/home_release_test.go+143 −0internal/meet/home_router_test.go+331 −0internal/meet/home_security_test.go+92 −0internal/meet/hub/messages.go+37 −95internal/meet/native_auth.go+241 −0internal/meet/native_auth_test.go+140 −0internal/meet/render.go+5 −1internal/meet/signal.go+2 −8internal/meet/store.go+14 −0internal/meet/templates/home.html+2 −0internal/meet/templates/index.html+1 −0internal/meet/templates/native.html+13 −0internal/meettest/native_auth_browser_test.go+109 −0internal/meettest/native_browser_test.go+79 −0internal/meettest/room_test.go+27 −0meetcore/auth.go+166 −0meetcore/auth_test.go+77 −0meetcore/room.go+540 −0meetcore/room_test.go+195 −0meetcore/transport.go+213 −0meetcore/transport_test.go+62 −0native/README.md+81 −0native/VERIFICATION.md+58 −0native/apple/Package.resolved+14 −0native/apple/Package.swift+21 −0native/apple/Sources/MeetInterop/Interop.swift+44 −0native/apple/Sources/MeetInterop/SyntheticVideo.swift+46 −0native/apple/Sources/MeetKit/Call.swift+177 −0native/apple/Sources/MeetKit/Capture.swift+60 −0native/apple/Sources/MeetKit/Media.swift+312 −0native/apple/Sources/MeetKit/Wire.swift+70 −0native/apple/Sources/MeetMac/MeetMac.swift+261 −0native/architecture.md+26 −0native/bridge/auth.go+29 −0native/bridge/bridge.go+78 −0native/bridge/bridge_test.go+31 −0native/go.mod+19 −0native/go.sum+14 −0native/scripts/build-bridge.sh+7 −0native/scripts/build-mac.sh+8 −0native/scripts/bundle-mac.py+36 −0protocol/auth.go+15 −0protocol/messages.go+102 −0protocol/signal.go+14 −0test/browser/native-auth.mjs+35 −0test/browser/native.mjs+96 −0
CI passed — run details
diff --git a/.gitignore b/.gitignore| index 8283e1f..39efeea 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -46,3 +46,8 @@ |
| Thumbs.db |
| .claude/settings.json |
| .claude/skills |
| + |
| +# Native build products and downloaded Apple frameworks. |
| +/native/build/ |
| +/native/apple/.build/ |
| +/native/apple/.swiftpm/ |
diff --git a/AGENTS.md b/AGENTS.md| index ed363e8..c9c5d9f 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -55,7 +55,10 @@ mechanically. |
| - The gate is `make ci` (vet + gofmt + tests) — the same definition |
| CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the |
| stack is cgo-free by design. |
| -- `make race` is the one exception to that rule, and the third gate: |
| +- Native Apple bindings under `native/` use cgo to bridge Go to Objective-C. |
| + Their toolchain is isolated in `native/go.mod`; server releases remain |
| + `CGO_ENABLED=0`. Build with `make native-mac`. |
| +- `make race` is the test exception to that rule, and the third gate: |
| `-race` refuses to build without cgo, so it sets `CGO_ENABLED=1`. It |
| builds a test binary and never a release, so nothing that ships gains |
| a C dependency. Run it for any change to `internal/meet/hub` or the |
diff --git a/Makefile b/Makefile| index 383fa5e..2606e9e 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,3 +1,5 @@ |
| +export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git |
| + |
| APP := meet |
| # What `carlos ship -target` defaults to, and therefore what a release |
| @@ -150,3 +152,16 @@ browser: |
| npm install --no-save playwright@1.62.1 |
| npx playwright install chromium |
| go test -tags browser -count=1 -timeout 20m -run TestCallInARealBrowser -v ./internal/meettest/ |
| + |
| +# Native builds isolate gomobile's cgo bridge from the cgo-free server. |
| +.PHONY: native-mac native-test native-browser |
| +native-mac: |
| + ./native/scripts/build-mac.sh |
| + |
| +native-test: |
| + CGO_ENABLED=0 go test ./meetcore ./protocol |
| + cd native && CGO_ENABLED=0 go test ./bridge |
| + CGO_ENABLED=1 go test -race ./meetcore |
| + |
| +native-browser: native-mac |
| + CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNative(MacCallsBrowser|BrowserAuthentication)' -v ./internal/meettest/ |
diff --git a/cmd/meet/main.go b/cmd/meet/main.go| index 31a34c0..d598e07 100644 |
| --- a/cmd/meet/main.go |
| +++ b/cmd/meet/main.go |
| @@ -13,6 +13,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/mail" |
| + "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/meet/internal/meet" |
| ) |
| @@ -51,6 +52,7 @@ func main() { |
| mailer := newMailer(logger) |
| cfg := meet.Config{ |
| + Home: connect.CredentialsFromEnv("MEET"), |
| Env: *env, |
| Origin: os.Getenv("MEET_ORIGIN"), |
| InstanceKey: os.Getenv("MEET_INSTANCE_KEY"), |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index b32eb49..9888270 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -868,6 +868,38 @@ outside `-env development`. This file already lives in Docs, Calendar |
| and Slides; Meet's is the fourth copy, and the extraction task Slides |
| recorded stands. Meet adds none. |
| +### 7.1.1 Optional Home sign-in (2026-09-10) |
| + |
| +The operator can enable Home with all four `MEET_HOME_ISSUER`, |
| +`MEET_HOME_CONSUMER_ID`, `MEET_HOME_KEYS_BASE64` and |
| +`MEET_HOME_DEK_BASE64` settings. Missing all four keeps standalone |
| +sign-in; partial settings refuse startup. The Home key is separate |
| +from the meeting-title key. |
| + |
| +Home links an existing member after fresh independent local sign-in, |
| +a fresh Home proof and explicit confirmation. It never creates a person, |
| +matches a Home email claim or grants membership. Both protected routes |
| +and the public meeting-link handler resolve Home subjects to the existing |
| +person ID, checking current membership. Existing meeting actors remain |
| +`person:<id>`. Person IDs are AUTOINCREMENT and cannot be recycled; |
| +any future account-deletion operation must also delete its Home links in |
| +the same transaction. |
| + |
| +`GET /signin` starts Home unless the browser has a pending guest-join |
| +cookie or is returning a local sign-in result. `GET /signin/local` is |
| +independent local sign-in. Its verification finishes at |
| +`/auth/local/complete`, then `/continue`, preserving the pending-join |
| +rules above. An explicit account-link continuation takes precedence. |
| +Guests still verify locally and cannot access `/account/home`. |
| + |
| +Shared `home/connect` owns `/auth/start`, `POST /auth/assertion`, |
| +`/auth/finish`, `/signed-out` and the GET/POST account-link routes |
| +`/account/home` and `/account/home/confirm`. Only the exact signed |
| +assertion callback bypasses ordinary CSRF protection; its issuer and |
| +proof are checked by the shared protocol. CSP preserves the framework's |
| +other directives and adds only the configured Home issuer to form-action. |
| +All account-link screens use the shared templates and work without scripts. |
| + |
| ### 7.2 Actors |
| Every write records an actor: `person:<id>`, or `agent:<id>` for the |
| @@ -998,7 +1030,7 @@ are drafts for that review, not approved copy. |
| Recurring meetings. Calendar integration. Recording. Attendance |
| records. A relay of Meet's own. Rooms above four. Persisted chat. |
| -Reactions. Dial-in. Captions. Home integration. Multi-host signalling. |
| +Reactions. Dial-in. Captions. Multi-host signalling. |
| Push-to-talk. Virtual backgrounds. A native client and its sign-in |
| (§8.4). Denying a knock (a guest who is not |
| admitted simply waits or leaves). Each is named so the next session |
diff --git a/go.mod b/go.mod| index a20abe5..63dbe54 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,15 +3,19 @@ module amadan.net/oficina/meet |
| go 1.26.0 |
| require ( |
| - amadan.net/rastrillo/rastrillo v0.26.0 |
| + amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/go-chi/chi/v5 v5.3.2 |
| golang.org/x/crypto v0.56.0 |
| gorm.io/gorm v1.31.2 |
| ) |
| require ( |
| + amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c // indirect |
| github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
diff --git a/go.sum b/go.sum| index c8aae48..e5fab7f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,11 @@ |
| -amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| -amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c h1:LgE7WLm6zMG/qZScoAHb4X2gguzorctB4XGjPYavXts= |
| +amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| +amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| +amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index a587440..3a7957a 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -31,6 +31,8 @@ type app struct { |
| store *store |
| logger *slog.Logger |
| auth *rauth.Auth |
| + home *homeIntegration |
| + nativeAuth *nativeAuthorization |
| linkBudget *budget |
| outsiderBudget *budget |
| hub *hub.Hub |
| @@ -382,6 +384,13 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| return nil, nil, err |
| } |
| a.auth = authPlugin |
| + if err := a.wireHome(); err != nil { |
| + return nil, nil, err |
| + } |
| + |
| + if err := a.wireNativeAuth(); err != nil { |
| + return nil, nil, err |
| + } |
| // Bounded, not open-ended: a boot sweep runs on the caller's ctx but |
| // must still finish, so an app never fails to start (or hangs a |
| @@ -400,32 +409,61 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| // page. apiRoutes says why that is safe — this surface honours no |
| // cookie, so there is no ambient credential for CSRF to protect. |
| r.Group(a.apiRoutes) |
| + r.Post("/native/request", a.nativeRequest) |
| + r.Post("/native/exchange", a.nativeExchange) |
| r.Group(func(r chi.Router) { |
| - r.Use(csrf.Protect(rc.Origin)) |
| - |
| - // Inside CSRF, outside the session: sign-in and /j/. |
| + if a.home == nil { |
| + r.Use(csrf.Protect(rc.Origin)) |
| + } else { |
| + r.Use(a.home.routes.Middleware) |
| + } |
| + r.Use(a.nativeResume) |
| + r.Get("/native/start", a.nativeStart) |
| r.Get(signinPath, a.signin) |
| - r.Post(signinPath, a.gateBegin(authPlugin.Begin)) |
| - r.Get("/auth/callback", authPlugin.Callback) |
| - r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + if a.home == nil { |
| + r.Post(signinPath, a.gateBegin(authPlugin.Begin)) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| + } else { |
| + r.Post(signinPath, a.home.routes.LocalGuard(a.gateBegin(authPlugin.Begin))) |
| + r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback)) |
| + r.Get("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) |
| + r.Post("/signout", a.home.routes.Signout) |
| + r.Get("/signin/local", a.localSignin) |
| + r.Get("/signed-out", a.home.routes.SignedOut) |
| + r.Get("/auth/start", a.home.bridge.Start) |
| + r.Post("/auth/assertion", a.home.bridge.Callback) |
| + r.Get("/auth/finish", a.home.bridge.Finish) |
| + r.Get("/auth/local/complete", a.home.routes.LocalComplete) |
| + } |
| r.Get("/j/{token}", a.followLink) |
| // Signed in, with a persons row. |
| r.Group(func(r chi.Router) { |
| r.Use(authPlugin.RequireSession) |
| r.Use(a.requirePerson) |
| + r.Get("/native/complete", a.nativeComplete) |
| + r.Get("/native/authorize", a.nativeAuthorize) |
| + r.Post("/native/authorize", a.nativeApprove) |
| r.Get("/continue", a.continueSignedIn) |
| r.Get("/welcome", a.welcome) |
| r.Get("/m/{sqid}/join", a.holding) |
| r.Get("/m/{sqid}", a.room) |
| + r.Get("/m/{sqid}/info", a.roomInfo) |
| r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove) |
| r.Post("/m/{sqid}/remove/{peer}", a.remove) |
| r.Get("/m/{sqid}/takeover", a.confirmTakeover) |
| r.Post("/m/{sqid}/takeover", a.takeover) |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireMember) |
| + if a.home != nil { |
| + r.Get("/account/home", a.home.routes.Account) |
| + r.Post("/account/home", a.home.routes.LinkStart) |
| + r.Get("/account/home/confirm", a.home.bridge.ConfirmPage) |
| + r.Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| + } |
| r.Get("/", a.index) |
| r.Post("/meetings/start", a.startCall) |
| r.Post("/meetings", a.createMeeting) |
diff --git a/internal/meet/auth.go b/internal/meet/auth.go| index db82a0b..d6ffc06 100644 |
| --- a/internal/meet/auth.go |
| +++ b/internal/meet/auth.go |
| @@ -3,6 +3,7 @@ package meet |
| import ( |
| "context" |
| "database/sql" |
| + "errors" |
| "net/http" |
| "net/url" |
| "strings" |
| @@ -20,6 +21,10 @@ const sentLocation = signinPath + "?sent=1" |
| // whose link is still live. The row was written before the mail went |
| // out, so the click is admitted in whichever browser it lands. |
| func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) { |
| + landing := "/continue" |
| + if a.cfg.home != nil { |
| + landing = "/auth/local/complete" |
| + } |
| return rauth.New(rauth.Config{ |
| DB: writer, |
| Origin: a.cfg.Origin, |
| @@ -27,7 +32,7 @@ func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) { |
| Mailer: a.cfg.mailer, |
| Authorize: a.authorize, |
| SigninPath: signinPath, |
| - SignedInPath: "/continue", |
| + SignedInPath: landing, |
| Logger: a.logger, |
| }) |
| } |
| @@ -152,7 +157,11 @@ func (a *app) requirePerson(next http.Handler) http.Handler { |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| - p, err := a.store.ensurePerson(r.Context(), s.Subject) |
| + p, err := a.personForSession(r.Context(), s) |
| + if errors.Is(err, errHomeIdentity) { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| if err != nil { |
| a.logger.Error("meet: ensure person", "err", err) |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| @@ -179,7 +188,7 @@ func (a *app) requireMember(next http.Handler) http.Handler { |
| }) |
| } |
| -func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| +func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| data := map[string]any{"Sent": r.URL.Query().Get("sent") == "1"} |
| switch r.URL.Query().Get("err") { |
| case "address": |
diff --git a/internal/meet/config.go b/internal/meet/config.go| index a09b88b..742ca59 100644 |
| --- a/internal/meet/config.go |
| +++ b/internal/meet/config.go |
| @@ -7,6 +7,7 @@ import ( |
| "strings" |
| "time" |
| + "amadan.net/oficina/home/connect" |
| "amadan.net/rastrillo/rastrillo/mail" |
| "amadan.net/oficina/meet/internal/meet/relay" |
| @@ -22,6 +23,7 @@ const EnvDevelopment = "development" |
| // secret or a boundary is a startup refusal when it is missing outside |
| // development, per spec §7.1. |
| type Config struct { |
| + Home *connect.Credentials |
| // Env is "development" or anything else (treated as production). |
| Env string |
| // Origin is the app's external origin, scheme included |
| @@ -60,6 +62,7 @@ var devDEK = make([]byte, 32) |
| // resolved is Config after every refusal has been checked and every |
| // default applied. |
| type resolved struct { |
| + home *connect.DecodedCredentials |
| Config |
| instanceKey string |
| dek []byte |
| @@ -154,5 +157,9 @@ func resolve(cfg Config) (resolved, error) { |
| } |
| out.zone = loc |
| out.Zone = zone |
| + out.home, err = connect.DecodeCredentials(cfg.Home, out.Origin) |
| + if err != nil { |
| + return out, err |
| + } |
| return out, nil |
| } |
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go| index 5a2b68f..9a989f5 100644 |
| --- a/internal/meet/handlers_join.go |
| +++ b/internal/meet/handlers_join.go |
| @@ -1,6 +1,8 @@ |
| package meet |
| import ( |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + "errors" |
| "net/http" |
| "github.com/go-chi/chi/v5" |
| @@ -26,7 +28,11 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, signinPath, http.StatusSeeOther) |
| return |
| } |
| - p, err := a.store.ensurePerson(r.Context(), id.Address) |
| + p, err := a.personForSession(r.Context(), sessions.Session{Subject: id.Address, Method: string(id.Method), AuthTime: id.AuthTime, At: id.At}) |
| + if errors.Is(err, errHomeIdentity) { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| if err != nil { |
| a.logger.Error("meet: ensure person", "err", err) |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go| index d9313bb..a764386 100644 |
| --- a/internal/meet/handlers_meetings.go |
| +++ b/internal/meet/handlers_meetings.go |
| @@ -89,7 +89,7 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star |
| } |
| render(w, "index", a.page(r, map[string]any{ |
| "Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone, |
| - "TitleError": titleErr, "StartError": startErr, |
| + "TitleError": titleErr, "StartError": startErr, "HomeEnabled": a.showHomeConnection(r), |
| })) |
| } |
diff --git a/internal/meet/handlers_room.go b/internal/meet/handlers_room.go| index c6304ef..7a6c808 100644 |
| --- a/internal/meet/handlers_room.go |
| +++ b/internal/meet/handlers_room.go |
| @@ -245,3 +245,13 @@ func (a *app) takeover(w http.ResponseWriter, r *http.Request) { |
| } |
| http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther) |
| } |
| + |
| +// roomInfo is the native holding screen's metadata. The room page's existing |
| +// authority resolves every field; callers never supply titles or standing. |
| +func (a *app) roomInfo(w http.ResponseWriter, r *http.Request) { |
| + m, p, st, ok := a.roomAccess(w, r) |
| + if !ok { |
| + return |
| + } |
| + streamJSON(w, http.StatusOK, map[string]any{"title": a.store.title(m), "name": p.Name, "standing": st.String(), "floor": !a.relayConfigured()}) |
| +} |
diff --git a/internal/meet/home.go b/internal/meet/home.go| new file mode 100644 |
| index 0000000..28026d4 |
| --- /dev/null |
| +++ b/internal/meet/home.go |
| @@ -0,0 +1,123 @@ |
| +package meet |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +var errHomeIdentity = errors.New("meet: Home identity refused") |
| + |
| +type homeIntegration struct { |
| + bridge *connect.Bridge |
| + routes *connect.Routes |
| + sessions *sessions.Sessions |
| +} |
| + |
| +func (a *app) wireHome() error { |
| + h := a.cfg.home |
| + if h == nil { |
| + return nil |
| + } |
| + if _, err := migrate.Apply(context.Background(), a.db, migrate.Merge(migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace))); err != nil { |
| + return errHomeIdentity |
| + } |
| + sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger}) |
| + if err != nil || sess.CookieName() != a.auth.SessionCookie() { |
| + return errHomeIdentity |
| + } |
| + read := func(r *http.Request) (*connect.Session, error) { |
| + s, ok := sess.From(r) |
| + if !ok { |
| + return nil, nil |
| + } |
| + return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil |
| + } |
| + b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: h.Issuer, ConsumerID: h.ConsumerID, Namespace: "oficina/meet", SessionCookie: sess.CookieName(), Keys: h.Keys, DEK: h.DEK, ReadSession: read, |
| + MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| + return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| + }, |
| + LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| + if s.Method != "magiclink" && s.Method != "keymail" { |
| + return connect.Person{}, errHomeIdentity |
| + } |
| + p, err := a.store.existingHomePerson(ctx, s.Subject) |
| + if err != nil || !a.isMember(p) { |
| + return connect.Person{}, errHomeIdentity |
| + } |
| + return connect.Person{ID: p.ID, Label: p.Email}, nil |
| + }, |
| + LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + p, err := a.store.homePersonByID(ctx, q, id) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !a.isMember(p) { |
| + return "", sql.ErrNoRows |
| + } |
| + return p.Email, nil |
| + }, Render: a.renderHome}) |
| + if err != nil { |
| + return errHomeIdentity |
| + } |
| + routes, err := connect.NewRoutes(b, connect.RoutesConfig{LocalLandingPath: "/continue", Namespace: "oficina/meet", Origin: a.cfg.Origin, Issuer: h.Issuer, SessionCookie: sess.CookieName(), DEK: h.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.localSignin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome}) |
| + if err != nil { |
| + return errHomeIdentity |
| + } |
| + a.home = &homeIntegration{b, routes, sess} |
| + return nil |
| +} |
| + |
| +func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person, error) { |
| + if s.Method == connect.HomeMethod { |
| + if a.home == nil { |
| + return Person{}, errHomeIdentity |
| + } |
| + linked, err := a.home.bridge.Resolve(ctx, s.Subject) |
| + if err != nil { |
| + return Person{}, errHomeIdentity |
| + } |
| + p, err := a.store.homePersonByID(ctx, a.db.Writer(), linked.ID) |
| + if err != nil || !a.isMember(p) { |
| + return Person{}, errHomeIdentity |
| + } |
| + return p, nil |
| + } |
| + if s.Method != "magiclink" && s.Method != "keymail" { |
| + return Person{}, errHomeIdentity |
| + } |
| + return a.store.ensurePerson(ctx, s.Subject) |
| +} |
| +func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| + if a.home != nil { |
| + if _, pending := a.readJoinCookie(r); !pending { |
| + a.home.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(w, r) |
| + return |
| + } |
| + } |
| + a.localSignin(w, r) |
| +} |
| +func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + var body bytes.Buffer |
| + if err := pages["home"].ExecuteTemplate(&body, "layout", a.page(r, map[string]any{"Home": connect.Page(state, label)})); err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(status) |
| + _, _ = w.Write(body.Bytes()) |
| +} |
| +func (a *app) showHomeConnection(r *http.Request) bool { |
| + s, ok := sessions.Current(r) |
| + return a.home != nil && ok && (s.Method == "magiclink" || s.Method == "keymail") |
| +} |
diff --git a/internal/meet/home_native_test.go b/internal/meet/home_native_test.go| new file mode 100644 |
| index 0000000..76245fe |
| --- /dev/null |
| +++ b/internal/meet/home_native_test.go |
| @@ -0,0 +1,121 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/meet/meetcore" |
| +) |
| + |
| +type nativeRoundTripper func(*http.Request) (*http.Response, error) |
| + |
| +func (f nativeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| + |
| +// The real shared Go client consumes a session produced by the existing Home |
| +// assertion and explicit linking ceremony. No test-only auth route is mounted. |
| +func TestNativeCoreUsesHomeSessionAndResumesCall(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + started := f.do("POST", "/meetings/start", "") |
| + match := regexp.MustCompile(`href="/m/([A-Za-z0-9]+)/join"`).FindStringSubmatch(started.Body.String()) |
| + if len(match) != 2 { |
| + t.Fatal("missing new meeting") |
| + } |
| + room := match[1] |
| + f.app.ttl.Store(int64(100 * time.Millisecond)) |
| + server := httptest.NewServer(f.handler) |
| + defer server.Close() |
| + target, _ := url.Parse(server.URL) |
| + cookies := []*http.Cookie{} |
| + for _, c := range f.cookies { |
| + cookies = append(cookies, c) |
| + } |
| + client := &http.Client{Transport: nativeRoundTripper(func(req *http.Request) (*http.Response, error) { |
| + copy := req.Clone(req.Context()) |
| + u := *req.URL |
| + copy.URL = &u |
| + copy.Host = req.URL.Host |
| + copy.URL.Host = target.Host |
| + copy.URL.Scheme = target.Scheme |
| + for _, c := range cookies { |
| + copy.AddCookie(c) |
| + } |
| + return http.DefaultTransport.RoundTrip(copy) |
| + })} |
| + events := make(chan meetcore.Event, 128) |
| + native, err := meetcore.New(homeTestOrigin, room, client, func(e meetcore.Event) { events <- e }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := native.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer native.Close() |
| + deadline := time.NewTimer(5 * time.Second) |
| + defer deadline.Stop() |
| + peer := "" |
| + rosters := 0 |
| + gotICE := false |
| + for rosters < 2 || !gotICE { |
| + select { |
| + case e := <-events: |
| + if e.Type == "ice" { |
| + gotICE = true |
| + } |
| + if e.Message != nil && e.Message.Type == "roster" { |
| + rosters++ |
| + s := native.Snapshot() |
| + if peer != "" && peer != string(s.Peer) { |
| + t.Fatal("resume replaced participation") |
| + } |
| + peer = string(s.Peer) |
| + } |
| + case <-deadline.C: |
| + t.Fatal("Home native client failed to join and resume") |
| + } |
| + } |
| + if w := f.do("POST", "/meetings/"+room+"/end", ""); w.Code != 303 { |
| + t.Fatalf("end: %d", w.Code) |
| + } |
| + for { |
| + select { |
| + case e := <-events: |
| + if e.State != nil && e.State.Phase == "ended" { |
| + if e.State.Reason != "ended" || len(e.State.Peers) != 0 { |
| + t.Fatalf("bad terminal state: %+v", e.State) |
| + } |
| + return |
| + } |
| + case <-deadline.C: |
| + t.Fatal("native client did not observe ended meeting") |
| + } |
| + } |
| +} |
| + |
| +func TestNativeCoreCannotUseAgentBearerAsHumanSession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + started := f.do("POST", "/meetings/start", "") |
| + match := regexp.MustCompile(`href="(/m/[A-Za-z0-9]+)/join"`).FindStringSubmatch(started.Body.String()) |
| + if len(match) != 2 { |
| + t.Fatal("missing meeting") |
| + } |
| + created := f.do("POST", "/settings/agents", "name=native-test") |
| + token := regexp.MustCompile(`value="(mt_[A-Za-z0-9_-]+)"`).FindStringSubmatch(created.Body.String()) |
| + if len(token) != 2 { |
| + t.Fatal("missing agent") |
| + } |
| + r := httptest.NewRequest("GET", homeTestOrigin+match[1]+"/stream", nil) |
| + r.Header.Set("Authorization", "Bearer "+token[1]) |
| + r.Header.Set("Accept", "text/event-stream") |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code == 200 || strings.Contains(w.Body.String(), "event: hello") { |
| + t.Fatal("agent entered human call") |
| + } |
| +} |
diff --git a/internal/meet/home_release_test.go b/internal/meet/home_release_test.go| new file mode 100644 |
| index 0000000..047a64a |
| --- /dev/null |
| +++ b/internal/meet/home_release_test.go |
| @@ -0,0 +1,143 @@ |
| +package meet |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestHomeConnectionKeepsCurrentCallAndAgentRoutes(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + started := f.do("POST", "/meetings/start", "") |
| + if started.Code != http.StatusOK { |
| + t.Fatalf("start call: %d", started.Code) |
| + } |
| + links := regexp.MustCompile(`href="(/m/[A-Za-z0-9]+/join)"`).FindStringSubmatch(started.Body.String()) |
| + if len(links) != 2 { |
| + t.Fatal("missing holding page link") |
| + } |
| + target := links[1] |
| + if !strings.HasPrefix(target, "/m/") { |
| + t.Fatal("start call lost room destination") |
| + } |
| + if w := f.do("GET", target, ""); w.Code != 200 { |
| + t.Fatalf("call page: %d", w.Code) |
| + } |
| + room := strings.TrimSuffix(target, "/join") |
| + if w := f.do("GET", room+"/stream?probe=1", ""); w.Code != http.StatusNoContent { |
| + t.Fatalf("Home stream probe: %d", w.Code) |
| + } |
| + server := httptest.NewServer(f.handler) |
| + defer server.Close() |
| + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, "GET", server.URL+room+"/stream", nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + origin, _ := url.Parse(homeTestOrigin) |
| + req.Host = origin.Host |
| + req.Header.Set("Accept", "text/event-stream") |
| + for _, c := range f.cookies { |
| + req.AddCookie(c) |
| + } |
| + resp, err := server.Client().Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != 200 { |
| + t.Fatalf("Home stream: %d", resp.StatusCode) |
| + } |
| + scanner := bufio.NewScanner(resp.Body) |
| + var hello struct{ Peer string } |
| + for scanner.Scan() { |
| + if strings.HasPrefix(scanner.Text(), "data:") { |
| + if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "data:"))), &hello); err != nil { |
| + t.Fatal(err) |
| + } |
| + if hello.Peer != "" { |
| + break |
| + } |
| + } |
| + } |
| + if hello.Peer == "" { |
| + t.Fatalf("Home stream missing hello: %v", scanner.Err()) |
| + } |
| + payload, err := json.Marshal(map[string]string{"type": "join", "peer": hello.Peer}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + signal := httptest.NewRequest("POST", homeTestOrigin+room+"/signal", strings.NewReader(string(payload))) |
| + signal.Header.Set("Origin", homeTestOrigin) |
| + signal.Header.Set("Content-Type", "application/json") |
| + for _, c := range f.cookies { |
| + signal.AddCookie(c) |
| + } |
| + joined := httptest.NewRecorder() |
| + f.handler.ServeHTTP(joined, signal) |
| + if joined.Code != 200 { |
| + t.Fatalf("Home join signal: %d", joined.Code) |
| + } |
| + if w := f.do("GET", room+"/ice?peer="+hello.Peer, ""); w.Code != 200 { |
| + t.Fatalf("Home admitted ICE: %d", w.Code) |
| + } |
| + resp.Body.Close() |
| + cancel() |
| + if w := f.do("GET", "/settings", ""); w.Code != 200 || !strings.Contains(w.Body.String(), "alice@example.test") { |
| + t.Fatalf("settings lost existing principal: %d", w.Code) |
| + } |
| + created := f.do("POST", "/settings/agents", "name=home-integration-test") |
| + if created.Code != 200 { |
| + t.Fatalf("create agent: %d", created.Code) |
| + } |
| + token := regexp.MustCompile(`value="(mt_[A-Za-z0-9_-]+)"`).FindStringSubmatch(created.Body.String()) |
| + if len(token) != 2 { |
| + t.Fatal("missing once-shown agent token") |
| + } |
| + api := func(method, path, body, bearer string, cookies bool) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + r.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + if cookies { |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + return w |
| + } |
| + if w := api("GET", "/api/v1/meetings", "", token[1], false); w.Code != 200 { |
| + t.Fatalf("bearer API with Home enabled: %d", w.Code) |
| + } |
| + if w := api("POST", "/api/v1/meetings", `{"title":"Agent call"}`, token[1], false); w.Code != 201 { |
| + t.Fatalf("bearer mutation incorrectly requires browser proof: %d", w.Code) |
| + } |
| + if w := api("GET", "/api/v1/meetings", "", "", true); w.Code != 401 { |
| + t.Fatalf("API accepted ambient Home cookie: %d", w.Code) |
| + } |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/meetings/start", nil) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 403 { |
| + t.Fatalf("browser mutation lost CSRF protection: %d", w.Code) |
| + } |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + if w := f.do("GET", target, ""); w.Code != 303 { |
| + t.Fatalf("room survives signout: %d", w.Code) |
| + } |
| +} |
diff --git a/internal/meet/home_router_test.go b/internal/meet/home_router_test.go| new file mode 100644 |
| index 0000000..4382892 |
| --- /dev/null |
| +++ b/internal/meet/home_router_test.go |
| @@ -0,0 +1,331 @@ |
| +package meet |
| + |
| +import ( |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const homeTestOrigin = "https://meet.example.test" |
| +const homeTestIssuer = "https://id.example.test" |
| + |
| +type homeTestMail struct{ body string } |
| + |
| +func (m *homeTestMail) Send(_ context.Context, _, _, body string) error { m.body = body; return nil } |
| + |
| +type homeRouterFixture struct { |
| + app *app |
| + handler http.Handler |
| + key *assertioncrypto.Keypair |
| + mail *homeTestMail |
| + cookies map[string]*http.Cookie |
| +} |
| + |
| +func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "meet.db"), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(key.SignPub()) |
| + id := hex.EncodeToString(sum[:]) |
| + raw, _ := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + mail := &homeTestMail{} |
| + mux, a, err := build(t.Context(), d, Config{Origin: homeTestOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)), AllowedAddresses: []string{"alice@example.test"}, Mailer: mail, Logger: logger, Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "meet-test", KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &homeRouterFixture{a, mux, key, mail, map[string]*http.Cookie{}} |
| +} |
| + |
| +func homeKeyID(key *assertioncrypto.Keypair) string { |
| + sum := sha256.Sum256(key.SignPub()) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| +func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body)) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + if method == "POST" { |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.Header.Set("Origin", homeTestOrigin) |
| + if path == "/auth/assertion" { |
| + r.Header.Set("Origin", homeTestIssuer) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + for _, c := range w.Result().Cookies() { |
| + if c.MaxAge < 0 { |
| + delete(f.cookies, c.Name) |
| + } else { |
| + f.cookies[c.Name] = c |
| + } |
| + } |
| + return w |
| +} |
| +func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string { |
| + t.Helper() |
| + if w.Code != 303 { |
| + t.Fatalf("redirect status %d: %s", w.Code, w.Body.String()) |
| + } |
| + return w.Header().Get("Location") |
| +} |
| +func (f *homeRouterFixture) local(t *testing.T) { |
| + t.Helper() |
| + homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1")) |
| + link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body) |
| + u, err := url.Parse(link) |
| + if err != nil || link == "" { |
| + t.Fatal("no captured local verification link") |
| + } |
| + landing := homeRedirect(t, f.do("GET", u.RequestURI(), "")) |
| + if landing != "/auth/local/complete" { |
| + t.Fatal("wrong local landing", landing) |
| + } |
| + landing = homeRedirect(t, f.do("GET", landing, "")) |
| + if landing != "/continue" { |
| + t.Fatal("lost local continue", landing) |
| + } |
| + homeRedirect(t, f.do("GET", landing, "")) |
| +} |
| +func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + target := homeRedirect(t, f.do("GET", start, "")) |
| + u, err := url.Parse(target) |
| + if err != nil || u.Query().Get("consumer") != "meet-test" { |
| + t.Fatal("bad issuer redirect") |
| + } |
| + nonce := u.Query().Get("nonce") |
| + now := time.Now().Unix() |
| + jti, err := assertion.RandomID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: homeTestOrigin, Subject: subject, KeyID: homeKeyID(f.key), ID: jti, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode()) |
| + finish := homeRedirect(t, callback) |
| + return f.do("GET", finish, "") |
| +} |
| +func (f *homeRouterFixture) link(t *testing.T) { |
| + f.local(t) |
| + if w := f.do("GET", "/account/home", ""); w.Code != 200 { |
| + t.Fatal("account", w.Code) |
| + } |
| + start := homeRedirect(t, f.do("POST", "/account/home", "")) |
| + if next := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); next != "/account/home/confirm" { |
| + t.Fatal("confirmation", next) |
| + } |
| + homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + start = homeRedirect(t, f.do("GET", "/signin", "")) |
| + homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| +} |
| +func TestHomeLinksExistingMemberAndKeepsMeetings(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + created := f.do("POST", "/meetings", "title=Existing+private+meeting") |
| + if created.Code != 200 && created.Code != 303 { |
| + t.Fatal(created.Code, created.Body.String()) |
| + } |
| + var before string |
| + if err := f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&before); err != nil { |
| + t.Fatal(err) |
| + } |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + f.link(t) |
| + w := f.do("GET", "/", "") |
| + if w.Code != 200 || !strings.Contains(w.Body.String(), "Existing private meeting") { |
| + t.Fatal("lost existing meeting", w.Code, w.Body.String()) |
| + } |
| + var after string |
| + f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&after) |
| + if after != before { |
| + t.Fatal("changed attribution") |
| + } |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&count) |
| + if count != 1 { |
| + t.Fatal("Home created person", count) |
| + } |
| + f.app.cfg.allow = allowlist{} |
| + if w = f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("removed member accepted", w.Code) |
| + } |
| +} |
| +func TestUnknownHomeDoesNotCreatePerson(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + w := f.exchange(t, start, "unknown-home-person") |
| + if w.Code != 403 { |
| + t.Fatal(w.Code) |
| + } |
| + var n int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n) |
| + if n != 0 { |
| + t.Fatal("Home allocated person", n) |
| + } |
| +} |
| +func TestHomeSessionFailsWhenDisabled(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + f.app.home = nil |
| + if w := f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("disabled accepted", w.Code) |
| + } |
| +} |
| +func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + 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()) |
| + if token == nil || sqid == nil { |
| + t.Fatal("missing meeting links") |
| + } |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + path := homeRedirect(t, f.do("GET", "/j/"+token[1], "")) |
| + if path != "/signin" { |
| + t.Fatal(path) |
| + } |
| + if w := f.do("GET", path, ""); w.Code != 200 { |
| + t.Fatal("guest redirected to Home", w.Code) |
| + } |
| + homeRedirect(t, f.do("POST", "/signin", "address=guest%40example.test")) |
| + link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body) |
| + u, _ := url.Parse(link) |
| + landing := homeRedirect(t, f.do("GET", u.RequestURI(), "")) |
| + if landing != "/auth/local/complete" { |
| + t.Fatal(landing) |
| + } |
| + landing = homeRedirect(t, f.do("GET", landing, "")) |
| + if landing != "/continue" { |
| + t.Fatal("lost pending guest landing", landing) |
| + } |
| + landing = homeRedirect(t, f.do("GET", landing, "")) |
| + if landing != "/m/"+sqid[1]+"/join" { |
| + t.Fatal("lost grant", landing) |
| + } |
| + if w := f.do("GET", landing, ""); w.Code != 200 { |
| + t.Fatal("holding", w.Code) |
| + } |
| + for _, path := range []string{"/", "/account/home"} { |
| + if w := f.do("GET", path, ""); w.Code != 404 { |
| + t.Fatal("guest gained member privilege", path, w.Code) |
| + } |
| + } |
| +} |
| +func TestHomePublicJoinResolvesExistingMember(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + made := f.do("POST", "/meetings", "title=Home+meeting") |
| + token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String()) |
| + if token == nil { |
| + t.Fatal("missing link") |
| + } |
| + if w := f.do("GET", "/j/"+token[1], ""); w.Code != 303 { |
| + t.Fatal("Home member follow", w.Code) |
| + } |
| + var n int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n) |
| + if n != 1 { |
| + t.Fatal("opaque Home subject became person", n) |
| + } |
| + f.app.cfg.allow = allowlist{} |
| + if w := f.do("GET", "/j/"+token[1], ""); w.Code != 404 { |
| + t.Fatal("removed member gained guest grant", w.Code) |
| + } |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM guest_grants").Scan(&n) |
| + if n != 0 { |
| + t.Fatal("removed Home member acquired grant", n) |
| + } |
| +} |
| +func TestHomeRemovedMemberSigninIsRefusedWithoutServiceError(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + f.app.cfg.allow = allowlist{} |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + if w := f.exchange(t, start, "opaque-home-alice"); w.Code != 403 { |
| + t.Fatal("removed member should be unknown, not unavailable", w.Code) |
| + } |
| +} |
| +func TestHomeLookupDatabaseFailureKeepsSession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + var before int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&before) |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE persons RENAME TO temporarily_unavailable_persons"); err != nil { |
| + t.Fatal(err) |
| + } |
| + w := f.do("GET", "/", "") |
| + if w.Code == 200 { |
| + t.Fatal("database failure admitted") |
| + } |
| + var after int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&after) |
| + if before != after { |
| + t.Fatal("transient lookup failure revoked session") |
| + } |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE temporarily_unavailable_persons RENAME TO persons"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if w = f.do("GET", "/", ""); w.Code != 200 { |
| + t.Fatal("recovered lookup did not preserve session", w.Code) |
| + } |
| +} |
| + |
| +func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + cookieName := f.app.home.sessions.CookieName() |
| + saved := f.cookies[cookieName].Value |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE sessions RENAME TO unavailable_sessions"); err != nil { |
| + t.Fatal(err) |
| + } |
| + homeRedirect(t, f.do("GET", "/signin", "")) |
| + if f.cookies[cookieName] == nil || f.cookies[cookieName].Value != saved { |
| + t.Fatal("temporary storage failure cleared valid cookie") |
| + } |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE unavailable_sessions RENAME TO sessions"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.app.db.Writer().Exec("DELETE FROM sessions"); err != nil { |
| + t.Fatal(err) |
| + } |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + if f.cookies[cookieName] != nil { |
| + t.Fatal("stale cookie not cleared") |
| + } |
| + target := homeRedirect(t, f.do("GET", start, "")) |
| + if !strings.HasPrefix(target, homeTestIssuer+"/") { |
| + t.Fatal("stale cookie prevented Home sign-in", target) |
| + } |
| +} |
diff --git a/internal/meet/home_security_test.go b/internal/meet/home_security_test.go| new file mode 100644 |
| index 0000000..55afff7 |
| --- /dev/null |
| +++ b/internal/meet/home_security_test.go |
| @@ -0,0 +1,92 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| +) |
| + |
| +func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) { |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| + baselineHandler, closeBaseline, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if closeBaseline != nil { |
| + defer closeBaseline() |
| + } |
| + rec := httptest.NewRecorder() |
| + baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/", nil)) |
| + baseline := rec.Header().Get("Content-Security-Policy") |
| + want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+homeTestIssuer, 1) |
| + if want == baseline { |
| + t.Fatal("framework form-action changed") |
| + } |
| + f := newHomeRouterFixture(t) |
| + wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if done != nil { |
| + defer done() |
| + } |
| + for _, path := range []string{"/signin/local", "/auth/start", "/account/home", "/signed-out"} { |
| + rec := httptest.NewRecorder() |
| + wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil)) |
| + if got := rec.Header().Get("Content-Security-Policy"); got != want { |
| + t.Fatalf("policy drift at %s: %q", path, got) |
| + } |
| + } |
| +} |
| +func TestHomeMutationOriginsAndDuplicateCookies(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + for _, path := range []string{"/signin", "/account/home", "/account/home/confirm", "/signout", "/meetings"} { |
| + r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader("address=alice%40example.test")) |
| + r.Header.Set("Origin", "https://other.example.test") |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 403 { |
| + t.Fatal("cross-origin accepted", path, w.Code) |
| + } |
| + } |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 400 { |
| + t.Fatal("duplicate cookies accepted", w.Code) |
| + } |
| +} |
| +func TestHomeCredentialsFailClosedWhenPartial(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + cfg := f.app.cfg.Config |
| + for _, name := range []string{"issuer", "consumer", "keys", "dek"} { |
| + broken := *cfg.Home |
| + switch name { |
| + case "issuer": |
| + broken.Issuer = "" |
| + case "consumer": |
| + broken.ConsumerID = "" |
| + case "keys": |
| + broken.KeysBase64 = "" |
| + case "dek": |
| + broken.DEKBase64 = "" |
| + } |
| + cfg.Home = &broken |
| + if _, err := resolve(cfg); err == nil { |
| + t.Fatal("partial accepted", name) |
| + } |
| + cfg = f.app.cfg.Config |
| + } |
| +} |
diff --git a/internal/meet/hub/messages.go b/internal/meet/hub/messages.go| index 4141824..2cb35b8 100644 |
| --- a/internal/meet/hub/messages.go |
| +++ b/internal/meet/hub/messages.go |
| @@ -1,101 +1,43 @@ |
| package hub |
| -import "encoding/json" |
| +import "amadan.net/oficina/meet/protocol" |
| -// PeerID is the server-issued identity of one participation: sixteen |
| -// random bytes in hex, bound to a room and a session, shown in rosters |
| -// so browsers can address each other. It is not a credential (spec §4.2). |
| -type PeerID string |
| - |
| -// Kind is a participation's authority, re-derived from standing on |
| -// every tick (spec §2.4). |
| -type Kind int |
| - |
| -// Reason says why a stream ended. Only "buffer" is followed by a |
| -// resume; the rest are terminal (spec §4.5). |
| -type Reason string |
| - |
| -const ( |
| - KindGuest Kind = iota |
| - KindMember |
| -) |
| - |
| -func (k Kind) String() string { |
| - if k == KindMember { |
| - return "member" |
| - } |
| - return "guest" |
| -} |
| +// Aliases keep the hub and native clients on one wire contract. |
| +type PeerID = protocol.PeerID |
| +type Kind = protocol.Kind |
| +type Reason = protocol.Reason |
| +type Message = protocol.Message |
| +type Roster = protocol.Roster |
| +type RosterPeer = protocol.RosterPeer |
| const ( |
| - ReasonBuffer Reason = "buffer" |
| - ReasonReplaced Reason = "replaced" |
| - ReasonStanding Reason = "standing" |
| - ReasonExpired Reason = "expired" |
| - ReasonRemoved Reason = "removed" |
| - ReasonEnded Reason = "ended" |
| + KindGuest = protocol.KindGuest |
| + ReasonBuffer = protocol.ReasonBuffer |
| + ReasonReplaced = protocol.ReasonReplaced |
| + ReasonStanding = protocol.ReasonStanding |
| + ReasonExpired = protocol.ReasonExpired |
| + ReasonRemoved = protocol.ReasonRemoved |
| + ReasonEnded = protocol.ReasonEnded |
| + TypeJoin = protocol.TypeJoin |
| + TypeAdmit = protocol.TypeAdmit |
| + TypeLeave = protocol.TypeLeave |
| + TypeOffer = protocol.TypeOffer |
| + TypeAnswer = protocol.TypeAnswer |
| + TypeCandidate = protocol.TypeCandidate |
| + TypeReset = protocol.TypeReset |
| + TypeChat = protocol.TypeChat |
| + TypeShare = protocol.TypeShare |
| + TypeHello = protocol.TypeHello |
| + TypeRoster = protocol.TypeRoster |
| + TypePeer = protocol.TypePeer |
| + TypeGone = protocol.TypeGone |
| + TypeKnock = protocol.TypeKnock |
| + TypeKnockGone = protocol.TypeKnockGone |
| + TypeFull = protocol.TypeFull |
| + TypeEnded = protocol.TypeEnded |
| + TypeLost = protocol.TypeLost |
| + TypePair = protocol.TypePair |
| + TypeGap = protocol.TypeGap |
| + TypeState = protocol.TypeState |
| + KindMember = protocol.KindMember |
| ) |
| - |
| -// Upstream types a browser may send. |
| -const ( |
| - TypeJoin = "join" |
| - TypeAdmit = "admit" |
| - TypeLeave = "leave" |
| - TypeOffer = "offer" |
| - TypeAnswer = "answer" |
| - TypeCandidate = "candidate" |
| - TypeReset = "reset" |
| - TypeChat = "chat" // slice 4 |
| - TypeShare = "share" // slice 4 |
| -) |
| - |
| -// Downstream types the server emits. |
| -const ( |
| - TypeHello = "hello" |
| - TypeRoster = "roster" |
| - TypePeer = "peer" |
| - TypeGone = "gone" |
| - TypeKnock = "knock" |
| - TypeKnockGone = "knock-gone" |
| - TypeFull = "full" |
| - // TypeEnded names the ending in §4.3's table, but nothing sends it: |
| - // a meeting that ends closes every stream, and a closing stream |
| - // carries its reason in lost, so the browser reads the ending as |
| - // lost{reason: "ended"} (§4.5). The name is kept because the spec |
| - // uses it. |
| - TypeEnded = "ended" |
| - TypeLost = "lost" |
| - TypePair = "pair" |
| - TypeGap = "gap" |
| - TypeState = "state" |
| -) |
| - |
| -// Message is the one envelope, built by the server. Payload is opaque. |
| -type Message struct { |
| - Type string `json:"type"` |
| - From PeerID `json:"from,omitempty"` |
| - Name string `json:"name,omitempty"` |
| - To PeerID `json:"to,omitempty"` |
| - Seq uint64 `json:"seq,omitempty"` |
| - Gen uint64 `json:"gen,omitempty"` |
| - Payload json.RawMessage `json:"payload,omitempty"` |
| - Reason Reason `json:"reason,omitempty"` |
| - Roster *Roster `json:"roster,omitempty"` |
| - Boot string `json:"boot,omitempty"` |
| - Peer PeerID `json:"peer,omitempty"` |
| - Waiting bool `json:"waiting,omitempty"` |
| -} |
| - |
| -// Roster is the authoritative snapshot a browser reconciles against |
| -// (spec §4.3). Waiting is filled only for a member recipient. |
| -type Roster struct { |
| - Peers []RosterPeer `json:"peers"` |
| - Waiting []RosterPeer `json:"waiting,omitempty"` |
| -} |
| - |
| -type RosterPeer struct { |
| - Peer PeerID `json:"peer"` |
| - Name string `json:"name"` |
| - Kind string `json:"kind"` |
| - Gen uint64 `json:"gen,omitempty"` |
| -} |
diff --git a/internal/meet/native_auth.go b/internal/meet/native_auth.go| new file mode 100644 |
| index 0000000..e20d9ca |
| --- /dev/null |
| +++ b/internal/meet/native_auth.go |
| @@ -0,0 +1,241 @@ |
| +package meet |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "amadan.net/oficina/meet/protocol" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// Native authorization transfers an existing browser identity into an ordinary |
| +// framework session. Requests expire on restart or after five minutes. Only the |
| +// app knows the verifier; browser URLs never contain session credentials. |
| +type nativeAuthorization struct { |
| + mu sync.Mutex |
| + pending map[string]*nativeRequest |
| + sessions *sessions.Sessions |
| +} |
| +type nativeRequest struct { |
| + Challenge string |
| + Target string |
| + Expires time.Time |
| + Cookie string |
| +} |
| + |
| +func (a *app) wireNativeAuth() error { |
| + s, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger}) |
| + if err != nil { |
| + return err |
| + } |
| + a.nativeAuth = &nativeAuthorization{pending: make(map[string]*nativeRequest), sessions: s} |
| + return nil |
| +} |
| +func nativeRandom() string { |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + panic(err) |
| + } |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| +func nativeToken(s string) bool { |
| + b, e := base64.RawURLEncoding.DecodeString(s) |
| + return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s |
| +} |
| +func nativeTarget(s string) bool { |
| + if !strings.HasPrefix(s, "/") { |
| + return false |
| + } |
| + u, e := url.Parse(s) |
| + if e != nil || u.IsAbs() || u.Host != "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" { |
| + return false |
| + } |
| + p := strings.Split(strings.TrimPrefix(s, "/"), "/") |
| + if len(p) != 2 || (p[0] != "m" && p[0] != "j") || len(p[1]) == 0 || len(p[1]) > 128 { |
| + return false |
| + } |
| + return strings.IndexFunc(p[1], func(r rune) bool { |
| + return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') |
| + }) < 0 |
| +} |
| +func (n *nativeAuthorization) find(id string) *nativeRequest { |
| + for k, v := range n.pending { |
| + if !time.Now().Before(v.Expires) { |
| + delete(n.pending, k) |
| + } |
| + } |
| + return n.pending[id] |
| +} |
| +func nativeHeaders(w http.ResponseWriter) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| +} |
| + |
| +// These two endpoints accept no ambient authority and never enable CORS. |
| +func (a *app) nativeRequest(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + if r.Header.Get("Content-Type") != "application/json" { |
| + http.Error(w, "expected JSON", 415) |
| + return |
| + } |
| + var input struct { |
| + Challenge string `json:"challenge"` |
| + Target string `json:"target"` |
| + } |
| + if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Challenge) || !nativeTarget(input.Target) { |
| + http.Error(w, "invalid request", 400) |
| + return |
| + } |
| + n := a.nativeAuth |
| + n.mu.Lock() |
| + defer n.mu.Unlock() |
| + n.find("") |
| + if len(n.pending) >= 256 { |
| + http.Error(w, "try again later", 429) |
| + 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") |
| + json.NewEncoder(w).Encode(map[string]string{"id": id}) |
| +} |
| +func (a *app) nativeExchange(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + if r.Header.Get("Content-Type") != "application/json" { |
| + http.Error(w, "expected JSON", 415) |
| + return |
| + } |
| + var input struct { |
| + ID string `json:"id"` |
| + Verifier string `json:"verifier"` |
| + } |
| + if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Verifier) { |
| + http.Error(w, "invalid request", 400) |
| + return |
| + } |
| + hash := sha256.Sum256([]byte(input.Verifier)) |
| + n := a.nativeAuth |
| + n.mu.Lock() |
| + defer n.mu.Unlock() |
| + p := n.find(input.ID) |
| + if p == nil || p.Challenge != base64.RawURLEncoding.EncodeToString(hash[:]) { |
| + http.Error(w, "request expired", 404) |
| + return |
| + } |
| + if p.Cookie == "" { |
| + w.WriteHeader(http.StatusAccepted) |
| + return |
| + } |
| + delete(n.pending, input.ID) // one attempt, including a revoked source session |
| + check := r.Clone(r.Context()) |
| + check.Header = make(http.Header) |
| + check.AddCookie(&http.Cookie{Name: n.sessions.CookieName(), Value: p.Cookie}) |
| + session, ok := n.sessions.From(check) |
| + if !ok { |
| + http.Error(w, "sign in again", 401) |
| + return |
| + } |
| + if _, err := a.personForSession(r.Context(), session); err != nil { |
| + http.Error(w, "sign in again", 401) |
| + return |
| + } |
| + token, err := n.sessions.Mint(session) |
| + if err != nil { |
| + http.Error(w, "cannot sign in", 500) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(map[string]string{"name": n.sessions.CookieName(), "value": token, "target": p.Target}) |
| +} |
| +func (a *app) nativeStart(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + n := a.nativeAuth |
| + n.mu.Lock() |
| + p := n.find(r.URL.Query().Get("id")) |
| + target := "" |
| + if p != nil { |
| + target = p.Target |
| + } |
| + n.mu.Unlock() |
| + if target == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.nativePending(w, r.URL.Query().Get("id"), 300) |
| + if strings.HasPrefix(target, "/m/") { |
| + target += "/join" |
| + } |
| + http.Redirect(w, r, target, http.StatusSeeOther) |
| +} |
| +func (a *app) nativePendingCookie() string { |
| + if strings.HasPrefix(a.cfg.Origin, "https:") { |
| + return "__Host-meet_native" |
| + } |
| + return "meet_native" |
| +} |
| +func (a *app) nativePending(w http.ResponseWriter, id string, age int) { |
| + http.SetCookie(w, &http.Cookie{Name: a.nativePendingCookie(), Value: id, Path: "/", HttpOnly: true, Secure: strings.HasPrefix(a.cfg.Origin, "https:"), SameSite: http.SameSiteLaxMode, MaxAge: age}) |
| +} |
| + |
| +// Run only after the normal sign-in continuation and invitation consumption. |
| +func (a *app) nativeResume(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + path := r.URL.Path |
| + if r.Method == "GET" && (path == "/" || path == "/welcome" || strings.HasPrefix(path, "/m/") && strings.HasSuffix(path, "/join")) { |
| + if c, e := r.Cookie(a.nativePendingCookie()); e == nil && nativeToken(c.Value) { |
| + if _, ok := a.nativeAuth.sessions.From(r); ok { |
| + a.nativePending(w, "", -1) |
| + http.Redirect(w, r, "/native/authorize?id="+c.Value, http.StatusSeeOther) |
| + return |
| + } |
| + } |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| +func (a *app) nativeAuthorize(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + id := r.URL.Query().Get("id") |
| + n := a.nativeAuth |
| + n.mu.Lock() |
| + p := n.find(id) |
| + valid := p != nil && p.Cookie == "" |
| + n.mu.Unlock() |
| + if !valid { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + render(w, "native", a.page(r, map[string]any{"Title": "Sign in to the Meet app", "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)})) |
| +} |
| +func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + c, e := r.Cookie(a.nativeAuth.sessions.CookieName()) |
| + if e != nil { |
| + http.Error(w, "sign in again", 401) |
| + return |
| + } |
| + n := a.nativeAuth |
| + n.mu.Lock() |
| + p := n.find(r.URL.Query().Get("id")) |
| + if p == nil || p.Cookie != "" { |
| + n.mu.Unlock() |
| + http.NotFound(w, r) |
| + return |
| + } |
| + p.Cookie = c.Value |
| + n.mu.Unlock() |
| + http.Redirect(w, r, "/native/complete", http.StatusSeeOther) |
| +} |
| + |
| +func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + render(w, "native", a.page(r, map[string]any{"Title": "Meet is signed in", "Done": true})) |
| +} |
diff --git a/internal/meet/native_auth_test.go b/internal/meet/native_auth_test.go| new file mode 100644 |
| index 0000000..be54ef6 |
| --- /dev/null |
| +++ b/internal/meet/native_auth_test.go |
| @@ -0,0 +1,140 @@ |
| +package meet |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect" |
| +) |
| + |
| +func nativeJSON(f *homeRouterFixture, path string, input any) *httptest.ResponseRecorder { |
| + b, _ := json.Marshal(input) |
| + r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(string(b))) |
| + r.Header.Set("Content-Type", "application/json") |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + return w |
| +} |
| +func nativeBegin(t *testing.T, f *homeRouterFixture) (string, string) { |
| + t.Helper() |
| + v := nativeRandom() |
| + h := sha256.Sum256([]byte(v)) |
| + w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/m/abc"}) |
| + var out map[string]string |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil || !nativeToken(out["id"]) { |
| + t.Fatal("request failed", w.Code) |
| + } |
| + return out["id"], v |
| +} |
| +func TestNativeHandoffPreservesHomeIdentityAndRequiresProof(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + id, v := nativeBegin(t, f) |
| + exchange := func(verifier string) *httptest.ResponseRecorder { |
| + return nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": verifier}) |
| + } |
| + if w := exchange(v); w.Code != 202 { |
| + t.Fatal("not pending", w.Code) |
| + } |
| + if w := f.do("GET", "/native/authorize?id="+id, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Sign in to Meet app") { |
| + t.Fatal("missing consent", w.Code) |
| + } |
| + if w := exchange(v); w.Code != 202 { |
| + t.Fatal("GET authorized", w.Code) |
| + } |
| + // Ambient browser cookies alone cannot approve from a foreign origin. |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/native/authorize?id="+id, nil) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + r.Header.Set("Origin", "https://attacker.test") |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 403 { |
| + t.Fatal("CSRF accepted", w.Code) |
| + } |
| + if w := f.do("POST", "/native/authorize?id="+id, ""); w.Code != 303 { |
| + t.Fatal("approve", w.Code) |
| + } |
| + if w := exchange(nativeRandom()); w.Code != 404 { |
| + t.Fatal("wrong proof accepted", w.Code) |
| + } |
| + w = exchange(v) |
| + if w.Code != 200 { |
| + t.Fatal("exchange", w.Code) |
| + } |
| + var result map[string]string |
| + json.Unmarshal(w.Body.Bytes(), &result) |
| + req := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + req.AddCookie(&http.Cookie{Name: result["name"], Value: result["value"]}) |
| + session, ok := f.app.nativeAuth.sessions.From(req) |
| + if !ok || session.Method != connect.HomeMethod { |
| + t.Fatal("lost Home identity") |
| + } |
| + browser := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + for _, c := range f.cookies { |
| + browser.AddCookie(c) |
| + } |
| + original, _ := f.app.nativeAuth.sessions.From(browser) |
| + if original.Subject != session.Subject || !original.AuthTime.Equal(session.AuthTime) || result["value"] == f.cookies[result["name"]].Value { |
| + t.Fatal("handoff changed identity or reused browser credential") |
| + } |
| + if exchange(v).Code != 404 { |
| + t.Fatal("replay accepted") |
| + } |
| +} |
| +func TestNativeHandoffEmailContinuationAndRevocation(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + id, v := nativeBegin(t, f) |
| + if dest := homeRedirect(t, f.do("GET", "/native/start?id="+id, "")); dest != "/m/abc/join" { |
| + t.Fatal(dest) |
| + } |
| + f.local(t) // follows the emailed link in the browser, keeping its pending cookie |
| + if dest := homeRedirect(t, f.do("GET", "/", "")); dest != "/native/authorize?id="+id { |
| + t.Fatal("email lost handoff", dest) |
| + } |
| + if _, ok := f.cookies[f.app.nativePendingCookie()]; ok { |
| + t.Fatal("pending cookie retained") |
| + } |
| + f.do("POST", "/native/authorize?id="+id, "") |
| + f.do("POST", "/signout", "") |
| + if w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}); w.Code != 401 { |
| + t.Fatal("revoked browser granted session", w.Code) |
| + } |
| +} |
| +func TestNativeHandoffExpiryAndTargetValidation(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + id, v := nativeBegin(t, f) |
| + f.app.nativeAuth.pending[id].Expires = time.Now().Add(-time.Second) |
| + if w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}); w.Code != 404 { |
| + t.Fatal("expired accepted") |
| + } |
| + for _, target := range []string{"https://evil.test/m/abc", "//evil.test/m/abc", "/j/../signin", "/m/a?next=evil", "/m/%2f%2fevil", "/m/a#evil", "m/a"} { |
| + if nativeTarget(target) { |
| + t.Errorf("unsafe target %q", target) |
| + } |
| + } |
| +} |
| + |
| +func TestNativeHandoffResumesAfterHomeAssertion(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + f.do("POST", "/signout", "") |
| + id, _ := nativeBegin(t, f) |
| + homeRedirect(t, f.do("GET", "/native/start?id="+id, "")) |
| + signin := homeRedirect(t, f.do("GET", "/m/abc/join", "")) |
| + start := homeRedirect(t, f.do("GET", signin, "")) |
| + destination := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| + if destination != "/" && destination != "/m/abc/join" { |
| + t.Fatal("unexpected Home destination", destination) |
| + } |
| + if got := homeRedirect(t, f.do("GET", destination, "")); got != "/native/authorize?id="+id { |
| + t.Fatal("Home lost native continuation", got) |
| + } |
| +} |
diff --git a/internal/meet/render.go b/internal/meet/render.go| index 2d2d4fb..9682040 100644 |
| --- a/internal/meet/render.go |
| +++ b/internal/meet/render.go |
| @@ -1,6 +1,7 @@ |
| package meet |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "bytes" |
| "embed" |
| "html/template" |
| @@ -17,13 +18,16 @@ var appFS embed.FS |
| var assets = rastrillo.NewAssets(appFS) |
| -var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once"} |
| +var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native"} |
| var pages = map[string]*template.Template{} |
| func init() { |
| for _, name := range pageNames { |
| pages[name] = buildPage(name) |
| + if name == "home" { |
| + pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html")) |
| + } |
| } |
| } |
diff --git a/internal/meet/signal.go b/internal/meet/signal.go| index 6d0ad9b..d894c05 100644 |
| --- a/internal/meet/signal.go |
| +++ b/internal/meet/signal.go |
| @@ -13,6 +13,7 @@ import ( |
| "github.com/go-chi/chi/v5" |
| "amadan.net/oficina/meet/internal/meet/hub" |
| + "amadan.net/oficina/meet/protocol" |
| ) |
| // maxSignalBody is spec §4.4's 64 KB cap, refused before decoding. |
| @@ -227,14 +228,7 @@ func safeSignalType(t string) string { |
| // with a syntactically valid JSON value — decoding into this struct is |
| // what validates the whole body, including payload, before any of it is |
| // trusted; nothing here is built from unvalidated bytes by hand. |
| -type signalBody struct { |
| - Peer hub.PeerID `json:"peer"` |
| - Type string `json:"type"` |
| - To hub.PeerID `json:"to,omitempty"` |
| - Seq uint64 `json:"seq,omitempty"` |
| - Gen uint64 `json:"gen,omitempty"` |
| - Payload json.RawMessage `json:"payload,omitempty"` |
| -} |
| +type signalBody = protocol.Signal |
| // signal is POST /m/{sqid}/signal: the upstream half of spec §4.1. The |
| // hub is the only authority on whether the named peer belongs to this |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index b02015b..b7626e4 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -579,3 +579,17 @@ func (s *store) rastrilloTextExpiry(ctx context.Context, table string) (time.Tim |
| } |
| return t, nil |
| } |
| + |
| +// Identity rows use AUTOINCREMENT and are never recycled for another person. |
| +func (s *store) existingHomePerson(ctx context.Context, address string) (Person, error) { |
| + var p Person |
| + err := s.g.WithContext(ctx).Where("email = ?", normalizeAddress(address)).Take(&p).Error |
| + return p, err |
| +} |
| +func (s *store) homePersonByID(ctx context.Context, q interface { |
| + QueryRowContext(context.Context, string, ...any) *sql.Row |
| +}, id int64) (Person, error) { |
| + var p Person |
| + err := q.QueryRowContext(ctx, "SELECT id, email, name FROM persons WHERE id = ?", id).Scan(&p.ID, &p.Email, &p.Name) |
| + return p, err |
| +} |
diff --git a/internal/meet/templates/home.html b/internal/meet/templates/home.html| new file mode 100644 |
| index 0000000..4dafa74 |
| --- /dev/null |
| +++ b/internal/meet/templates/home.html |
| @@ -0,0 +1,2 @@ |
| +{{define "title"}}{{.Home.Title}} · Meet{{end}} |
| +{{define "content"}}{{template "home-connect" .Home}}{{end}} |
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html| index feb4ef9..85dfb4c 100644 |
| --- a/internal/meet/templates/index.html |
| +++ b/internal/meet/templates/index.html |
| @@ -1,6 +1,7 @@ |
| {{define "title"}}Meetings{{end}} |
| {{define "content"}} |
| {{template "page-header" dict "Title" "Meetings"}} |
| +{{if .HomeEnabled}}<p><a href="/account/home" rst-btn="ghost">Connect Home account</a></p>{{end}} |
| <section> |
| <form method="post" action="/meetings/start"><button type="submit" rst-btn="primary lg">Start a call</button></form> |
| </section> |
diff --git a/internal/meet/templates/native.html b/internal/meet/templates/native.html| new file mode 100644 |
| index 0000000..018627e |
| --- /dev/null |
| +++ b/internal/meet/templates/native.html |
| @@ -0,0 +1,13 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" .Title}} |
| +<div rst-box> |
| +{{if .Done}} |
| +<p>Return to the Meet app to join your meeting. You can close this tab.</p> |
| +{{else}} |
| +<p>Check that this code matches the one in your Meet app: <strong class="rst-mono">{{.Code}}</strong></p> |
| +<p>Continue only if you just started signing in from the Meet app on your own device. This gives that app access to Meet as the account shown above.</p> |
| +{{template "confirm-form" dict "Action" .Action "Label" "Sign in to Meet app" "CancelHref" "/" "CancelLabel" "Cancel"}} |
| +{{end}} |
| +</div> |
| +{{end}} |
diff --git a/internal/meettest/native_auth_browser_test.go b/internal/meettest/native_auth_browser_test.go| new file mode 100644 |
| index 0000000..ee61172 |
| --- /dev/null |
| +++ b/internal/meettest/native_auth_browser_test.go |
| @@ -0,0 +1,109 @@ |
| +//go:build browser |
| + |
| +package meettest |
| + |
| +import ( |
| + "bufio" |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/cookiejar" |
| + "net/url" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/meet/meetcore" |
| +) |
| + |
| +func TestNativeBrowserAuthentication(t *testing.T) { |
| + for _, guest := range []bool{false, true} { |
| + name := "member" |
| + if guest { |
| + name = "guest" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + rig := newBrowserRig(t) |
| + room, token := rig.createMeeting(t) |
| + address := memberB |
| + target := "/m/" + room |
| + if guest { |
| + rig.ctl.SetAllowlist(memberA) |
| + target = "/j/" + token |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) |
| + defer cancel() |
| + auth, err := meetcore.BeginAuthentication(ctx, rig.origin+target) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer auth.Cancel() |
| + cmd := exec.CommandContext(ctx, "node", filepath.Join(repoRootForBrowser(t), "test/browser/native-auth.mjs")) |
| + stdin, err := cmd.StdinPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stdout, 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) |
| + } |
| + defer stdin.Close() |
| + json.NewEncoder(stdin).Encode(map[string]string{"url": auth.BrowserURL(), "address": address, "code": auth.ConfirmationCode()}) |
| + scanner := bufio.NewScanner(stdout) |
| + approved := false |
| + for scanner.Scan() { |
| + switch scanner.Text() { |
| + case "mail": |
| + to, link := rig.mail.last() |
| + if to != address || link == "" { |
| + cancel() |
| + cmd.Wait() |
| + t.Fatal("missing email") |
| + } |
| + json.NewEncoder(stdin).Encode(map[string]string{"link": link}) |
| + case "stage:signin", "stage:email-opened": |
| + t.Log(scanner.Text()) |
| + case "approved": |
| + approved = true |
| + default: |
| + cancel() |
| + cmd.Wait() |
| + t.Fatal("unexpected browser output") |
| + } |
| + } |
| + if err := cmd.Wait(); err != nil { |
| + t.Fatalf("browser: %v: %s", err, stderr.String()) |
| + } |
| + if !approved { |
| + t.Fatal("browser did not approve") |
| + } |
| + result, err := auth.Wait() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if result.Room != room || result.Origin != rig.origin || len(result.Cookies) != 1 { |
| + t.Fatal("wrong native destination") |
| + } |
| + jar, _ := cookiejar.New(nil) |
| + origin, _ := url.Parse(rig.origin) |
| + c := result.Cookies[0] |
| + jar.SetCookies(origin, []*http.Cookie{{Name: c.Name, Value: c.Value}}) |
| + client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + res, err := client.Get(rig.origin + "/m/" + room + "/info") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != 200 { |
| + t.Fatalf("native session cannot access meeting: %d", res.StatusCode) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/meettest/native_browser_test.go b/internal/meettest/native_browser_test.go| new file mode 100644 |
| index 0000000..8c39cdf |
| --- /dev/null |
| +++ b/internal/meettest/native_browser_test.go |
| @@ -0,0 +1,79 @@ |
| +//go:build browser && darwin |
| + |
| +package meettest |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// This gate uses the shipping Go bridge and WebRTC implementation with a |
| +// synthetic camera. Browser and native credentials come from real magic links. |
| +func TestNativeMacCallsBrowser(t *testing.T) { |
| + for _, guest := range []bool{false, true} { |
| + name := "member" |
| + if guest { |
| + name = "guest" |
| + } |
| + t.Run(name, func(t *testing.T) { nativeMacCallsBrowser(t, guest) }) |
| + } |
| +} |
| +func nativeMacCallsBrowser(t *testing.T, guest bool) { |
| + rig := newBrowserRig(t) |
| + room, token := rig.createMeeting(t) |
| + mode := "member" |
| + if guest { |
| + mode = "guest" |
| + rig.ctl.SetAllowlist(memberA) |
| + req, err := http.NewRequest("GET", rig.origin+"/j/"+token, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieB}) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + res.Body.Close() |
| + if res.StatusCode != 200 { |
| + t.Fatalf("guest link: %d", res.StatusCode) |
| + } |
| + } |
| + rig.ctl.SetStreamTimings(time.Second, 3*time.Second) |
| + root, err := filepath.Abs("../..") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binary := filepath.Join(root, "native/apple/.build/debug/MeetInterop") |
| + if _, err := os.Stat(binary); err != nil { |
| + t.Fatal("build native interop executable with make native-mac first") |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native.mjs"), rig.origin, room, binary, mode) |
| + cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| + input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)}) |
| + cmd.Env = append(os.Environ(), "MEET_COOKIE_NAME="+rig.cookieName, "MEET_COOKIE_A="+rig.cookieA, "MEET_NATIVE_INPUT="+string(input)) |
| + out, err := cmd.StdoutPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd.Stderr = cmd.Stdout |
| + if err := cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + scanner := bufio.NewScanner(out) |
| + for scanner.Scan() { |
| + t.Log(scanner.Text()) |
| + } |
| + if err := cmd.Wait(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go| index 7098360..1998811 100644 |
| --- a/internal/meettest/room_test.go |
| +++ b/internal/meettest/room_test.go |
| @@ -1,6 +1,7 @@ |
| package meettest |
| import ( |
| + "encoding/json" |
| "net/http" |
| "net/url" |
| "strings" |
| @@ -575,3 +576,29 @@ func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) { |
| t.Fatalf("the message box must not be `required`: an empty Send would raise a browser validation bubble that Enter cannot, and that this app's copy list does not own: %q", tag) |
| } |
| } |
| + |
| +func TestNativeRoomInfoUsesRoomAuthority(t *testing.T) { |
| + x, room, token := memberWithMeeting(t) |
| + member := x.browser(t) |
| + member.signIn("paul@example.com") |
| + other, _ := createMeeting(t, member, "Private title", "") |
| + guest := x.browser(t) |
| + guest.get("/j/" + token) |
| + guest.signIn("guest@example.com") |
| + res := guest.get("/m/" + room + "/info") |
| + var info struct { |
| + Title string |
| + Standing string |
| + Floor bool |
| + } |
| + if res.Code != 200 || json.Unmarshal(res.Body.Bytes(), &info) != nil || info.Title != "Board meeting" || info.Standing != "guest" { |
| + t.Fatalf("metadata: %d %+v", res.Code, info) |
| + } |
| + if res.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("metadata may be cached") |
| + } |
| + res = guest.get("/m/" + other + "/info") |
| + if res.Code != 404 || strings.Contains(res.Body.String(), "Private title") { |
| + t.Fatal("metadata leaked another meeting") |
| + } |
| +} |
diff --git a/meetcore/auth.go b/meetcore/auth.go| new file mode 100644 |
| index 0000000..c225ebf |
| --- /dev/null |
| +++ b/meetcore/auth.go |
| @@ -0,0 +1,166 @@ |
| +package meetcore |
| + |
| +import ( |
| + "amadan.net/oficina/meet/protocol" |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Authentication keeps the proof secret in the client while the user approves |
| +// in their default browser. It works on desktop and mobile without a webview, |
| +// custom URL scheme, or browser cookie extraction. |
| +type Authentication struct { |
| + origin, id, verifier, target string |
| + client *http.Client |
| + ctx context.Context |
| + cancel context.CancelFunc |
| +} |
| +type AuthenticationResult struct { |
| + Origin string `json:"origin"` |
| + Room string `json:"room"` |
| + Cookies []SessionCookie `json:"cookies"` |
| +} |
| +type SessionCookie struct { |
| + Name string `json:"name"` |
| + Value string `json:"value"` |
| +} |
| + |
| +func BeginAuthentication(ctx context.Context, address string) (*Authentication, error) { |
| + u, err := url.Parse(address) |
| + if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" { |
| + return nil, errors.New("enter a full meeting or invitation link") |
| + } |
| + parts := strings.Split(strings.Trim(u.Path, "/"), "/") |
| + if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" { |
| + parts = parts[:2] |
| + } |
| + if len(parts) != 2 || (parts[0] != "m" && parts[0] != "j") || len(parts[1]) == 0 || len(parts[1]) > 128 || strings.IndexFunc(parts[1], func(r rune) bool { |
| + return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') |
| + }) >= 0 { |
| + return nil, errors.New("enter a full meeting or invitation link") |
| + } |
| + origin := u.Scheme + "://" + u.Host |
| + transport, err := newTransport(origin, "auth", &http.Client{}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) |
| + raw := make([]byte, 32) |
| + if _, err = rand.Read(raw); err != nil { |
| + cancel() |
| + return nil, errors.New("cannot start sign-in") |
| + } |
| + a := &Authentication{origin: origin, verifier: base64.RawURLEncoding.EncodeToString(raw), target: "/" + strings.Join(parts, "/"), client: transport.http, ctx: ctx, cancel: cancel} |
| + hash := sha256.Sum256([]byte(a.verifier)) |
| + res, err := a.post("request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(hash[:]), "target": a.target}) |
| + if err != nil { |
| + cancel() |
| + return nil, err |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != 200 { |
| + cancel() |
| + return nil, errors.New("this Meet server does not support native sign-in yet, or is temporarily unavailable") |
| + } |
| + var out struct { |
| + ID string `json:"id"` |
| + } |
| + if json.NewDecoder(io.LimitReader(res.Body, 2048)).Decode(&out) != nil || !authToken(out.ID) { |
| + cancel() |
| + return nil, errors.New("invalid sign-in response") |
| + } |
| + a.id = out.ID |
| + return a, nil |
| +} |
| +func authToken(s string) bool { |
| + b, e := base64.RawURLEncoding.DecodeString(s) |
| + return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s |
| +} |
| +func (a *Authentication) BrowserURL() string { return a.origin + "/native/start?id=" + a.id } |
| +func (a *Authentication) Cancel() { a.cancel() } |
| +func (a *Authentication) post(path string, input any) (*http.Response, error) { |
| + b, _ := json.Marshal(input) |
| + ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second) |
| + // Keep the request deadline alive through the response body read. |
| + req, _ := http.NewRequestWithContext(ctx, "POST", a.origin+"/native/"+path, bytes.NewReader(b)) |
| + req.Header.Set("Content-Type", "application/json") |
| + res, e := a.client.Do(req) |
| + if e != nil { |
| + cancel() |
| + return nil, errors.New("cannot reach Meet; try signing in again") |
| + } |
| + res.Body = &authBody{ReadCloser: res.Body, cancel: cancel} |
| + return res, nil |
| +} |
| + |
| +type authBody struct { |
| + io.ReadCloser |
| + cancel context.CancelFunc |
| +} |
| + |
| +func (b *authBody) Close() error { defer b.cancel(); return b.ReadCloser.Close() } |
| +func (a *Authentication) Wait() (*AuthenticationResult, error) { |
| + defer a.cancel() |
| + timer := time.NewTicker(2 * time.Second) |
| + defer timer.Stop() |
| + for { |
| + select { |
| + case <-a.ctx.Done(): |
| + return nil, errors.New("sign-in cancelled or expired; try again") |
| + case <-timer.C: |
| + } |
| + res, e := a.post("exchange", map[string]string{"id": a.id, "verifier": a.verifier}) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if res.StatusCode == 202 { |
| + res.Body.Close() |
| + continue |
| + } |
| + var out struct { |
| + Name string `json:"name"` |
| + Value string `json:"value"` |
| + Target string `json:"target"` |
| + } |
| + err := json.NewDecoder(io.LimitReader(res.Body, 2048)).Decode(&out) |
| + res.Body.Close() |
| + expected := "__Host-rastrillo_session" |
| + if strings.HasPrefix(a.origin, "http:") { |
| + expected = "rastrillo_session" |
| + } |
| + if res.StatusCode != 200 || err != nil || out.Name != expected || !authToken(out.Value) || out.Target != a.target { |
| + return nil, errors.New("sign-in expired or was refused; try again") |
| + } |
| + room := strings.TrimPrefix(a.target, "/m/") |
| + if strings.HasPrefix(a.target, "/j/") { |
| + req, _ := http.NewRequestWithContext(a.ctx, "GET", a.origin+a.target, nil) |
| + req.AddCookie(&http.Cookie{Name: out.Name, Value: out.Value}) |
| + response, err := a.client.Do(req) |
| + if err != nil { |
| + return nil, errors.New("cannot open invitation") |
| + } |
| + response.Body.Close() |
| + location, err := url.Parse(response.Header.Get("Location")) |
| + if err != nil || response.StatusCode != 303 || location.IsAbs() || location.Host != "" || !strings.HasPrefix(location.Path, "/m/") || !strings.HasSuffix(location.Path, "/join") { |
| + return nil, errors.New("invitation is unavailable") |
| + } |
| + room = strings.TrimSuffix(strings.TrimPrefix(location.Path, "/m/"), "/join") |
| + } |
| + if _, e := newTransport(a.origin, room, a.client); e != nil { |
| + return nil, e |
| + } |
| + return &AuthenticationResult{Origin: a.origin, Room: room, Cookies: []SessionCookie{{Name: out.Name, Value: out.Value}}}, nil |
| + } |
| +} |
| + |
| +func (a *Authentication) ConfirmationCode() string { return protocol.AuthenticationCode(a.id) } |
diff --git a/meetcore/auth_test.go b/meetcore/auth_test.go| new file mode 100644 |
| index 0000000..4632692 |
| --- /dev/null |
| +++ b/meetcore/auth_test.go |
| @@ -0,0 +1,77 @@ |
| +package meetcore |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestAuthenticationProofAndCancellation(t *testing.T) { |
| + id := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| + challengeReceived := make(chan string, 1) |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get("Cookie") != "" { |
| + t.Error("authentication sent ambient credentials") |
| + } |
| + var in map[string]string |
| + json.NewDecoder(r.Body).Decode(&in) |
| + if r.URL.Path != "/native/request" || !authToken(in["challenge"]) || in["target"] != "/m/abc" { |
| + t.Error("invalid authentication request") |
| + } |
| + challengeReceived <- in["challenge"] |
| + json.NewEncoder(w).Encode(map[string]string{"id": id}) |
| + })) |
| + defer server.Close() |
| + auth, err := BeginAuthentication(t.Context(), server.URL+"/m/abc/join") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + challenge := <-challengeReceived |
| + sum := sha256.Sum256([]byte(auth.verifier)) |
| + if challenge != base64.RawURLEncoding.EncodeToString(sum[:]) { |
| + t.Fatal("challenge does not bind proof") |
| + } |
| + if strings.Contains(auth.BrowserURL(), auth.verifier) || strings.Contains(auth.BrowserURL(), challenge) { |
| + t.Fatal("proof leaked into browser URL") |
| + } |
| + if auth.ConfirmationCode() == "" { |
| + t.Fatal("missing comparison code") |
| + } |
| + // Cancellation must interrupt the wait promptly, including while idle between polls. |
| + auth.Cancel() |
| + done := make(chan error, 1) |
| + go func() { _, e := auth.Wait(); done <- e }() |
| + select { |
| + case e := <-done: |
| + if e == nil { |
| + t.Fatal("cancel succeeded") |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("cancel did not stop polling") |
| + } |
| +} |
| +func TestAuthenticationNeverFollowsRedirect(t *testing.T) { |
| + var calls atomic.Int32 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); http.Redirect(w, r, "/signin", 303) })) |
| + defer server.Close() |
| + if _, err := BeginAuthentication(t.Context(), server.URL+"/m/abc"); err == nil { |
| + t.Fatal("old server accepted") |
| + } |
| + if calls.Load() != 1 { |
| + t.Fatal("followed authentication redirect") |
| + } |
| +} |
| +func TestAuthenticationRejectsUnsafeAddress(t *testing.T) { |
| + for _, address := range []string{"http://example.test/m/abc", "https://alice:secret@example.test/m/abc", "https://example.test/m/abc?token=secret", "https://example.test/j/a#secret", "https://example.test/signin", "https://example.test/j/%2f"} { |
| + if _, err := BeginAuthentication(context.Background(), address); err == nil { |
| + t.Errorf("accepted %q", address) |
| + } |
| + } |
| +} |
diff --git a/meetcore/room.go b/meetcore/room.go| new file mode 100644 |
| index 0000000..9f9be93 |
| --- /dev/null |
| +++ b/meetcore/room.go |
| @@ -0,0 +1,540 @@ |
| +package meetcore |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "sort" |
| + "sync" |
| + "time" |
| + |
| + "amadan.net/oficina/meet/protocol" |
| +) |
| + |
| +// Event crosses the native bridge as JSON. Message is a server envelope; |
| +// snapshot events carry authoritative local state. Media never crosses it. |
| +type Event struct { |
| + Info *Info `json:"info,omitempty"` |
| + Type string `json:"type"` |
| + State *Snapshot `json:"state,omitempty"` |
| + Message *protocol.Message `json:"message,omitempty"` |
| + ICE *ICEServers `json:"ice,omitempty"` |
| +} |
| +type Snapshot struct { |
| + Phase string `json:"phase"` |
| + Peer protocol.PeerID `json:"peer"` |
| + Boot string `json:"boot"` |
| + Reason string `json:"reason,omitempty"` |
| + Peers []protocol.RosterPeer `json:"peers"` |
| + Waiting []protocol.RosterPeer `json:"waiting"` |
| +} |
| + |
| +type lane struct { |
| + queue chan protocol.Signal |
| + ctx context.Context |
| + cancel context.CancelFunc |
| +} |
| + |
| +// Room is single-use. Callbacks are serialized and must return promptly. A host |
| +// should enqueue them on its UI executor, never perform media work inline. |
| +type Room struct { |
| + mu sync.Mutex |
| + emitMu sync.Mutex |
| + t *transport |
| + callback func(Event) |
| + ctx context.Context |
| + cancel context.CancelFunc |
| + wg sync.WaitGroup |
| + started bool |
| + stopped bool |
| + state Snapshot |
| + last map[protocol.PeerID]uint64 |
| + seq map[protocol.PeerID]uint64 |
| + lanes map[protocol.PeerID]*lane |
| + blocked map[protocol.PeerID]bool |
| + iceWake chan struct{} |
| +} |
| + |
| +func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) { |
| + t, err := newTransport(origin, room, authenticated) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if callback == nil { |
| + return nil, errors.New("a room event handler is required") |
| + } |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + return &Room{t: t, callback: callback, ctx: ctx, cancel: cancel, state: Snapshot{Phase: "ready", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}}, last: map[protocol.PeerID]uint64{}, seq: map[protocol.PeerID]uint64{}, lanes: map[protocol.PeerID]*lane{}, blocked: map[protocol.PeerID]bool{}, iceWake: make(chan struct{}, 1)}, nil |
| +} |
| +func (r *Room) emit(e Event) { r.emitMu.Lock(); defer r.emitMu.Unlock(); r.callback(e) } |
| +func (r *Room) snapshotLocked() Snapshot { |
| + s := r.state |
| + s.Peers = append([]protocol.RosterPeer{}, s.Peers...) |
| + s.Waiting = append([]protocol.RosterPeer{}, s.Waiting...) |
| + return s |
| +} |
| +func (r *Room) Snapshot() Snapshot { r.mu.Lock(); defer r.mu.Unlock(); return r.snapshotLocked() } |
| +func (r *Room) publish() { s := r.Snapshot(); r.emit(Event{Type: "state", State: &s}) } |
| +func (r *Room) Start() error { |
| + r.mu.Lock() |
| + if r.started || r.stopped { |
| + r.mu.Unlock() |
| + return errors.New("room already started or closed") |
| + } |
| + r.started = true |
| + r.state.Phase = "connecting" |
| + r.wg.Add(2) |
| + r.mu.Unlock() |
| + r.publish() |
| + go func() { defer r.wg.Done(); r.streamLoop() }() |
| + go func() { defer r.wg.Done(); r.iceLoop() }() |
| + return nil |
| +} |
| + |
| +// Close cancels all network work and waits for callbacks to finish. Do not call |
| +// it from a callback. Leave attempts the departure before closing. |
| +func (r *Room) Close() { |
| + r.mu.Lock() |
| + r.stopped = true |
| + r.cancel() |
| + r.mu.Unlock() |
| + r.wg.Wait() |
| +} |
| +func (r *Room) Leave() { |
| + r.mu.Lock() |
| + peer := r.state.Peer |
| + r.mu.Unlock() |
| + if peer != "" { |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + _ = r.t.post(ctx, protocol.Signal{Peer: peer, Type: protocol.TypeLeave}) |
| + cancel() |
| + } |
| + r.terminal("left") |
| + r.Close() |
| +} |
| +func (r *Room) terminal(reason string) { |
| + r.mu.Lock() |
| + if r.stopped { |
| + r.mu.Unlock() |
| + return |
| + } |
| + r.stopped = true |
| + r.state.Phase = "ended" |
| + r.state.Reason = reason |
| + r.state.Peers = []protocol.RosterPeer{} |
| + r.state.Waiting = []protocol.RosterPeer{} |
| + r.cancel() |
| + r.mu.Unlock() |
| + r.publish() |
| +} |
| +func (r *Room) fresh() { |
| + r.mu.Lock() |
| + r.state = Snapshot{Phase: "connecting", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}} |
| + r.last = map[protocol.PeerID]uint64{} |
| + r.seq = map[protocol.PeerID]uint64{} |
| + for _, l := range r.lanes { |
| + l.cancel() |
| + } |
| + r.lanes = map[protocol.PeerID]*lane{} |
| + r.blocked = map[protocol.PeerID]bool{} |
| + r.mu.Unlock() |
| + r.publish() |
| +} |
| +func (r *Room) streamLoop() { |
| + if info, err := r.t.info(r.ctx); err == nil { |
| + r.emit(Event{Type: "info", Info: &info}) |
| + } |
| + |
| + delay := 500 * time.Millisecond |
| + for r.ctx.Err() == nil { |
| + r.mu.Lock() |
| + peer := r.state.Peer |
| + q := url.Values{} |
| + if peer != "" { |
| + b, _ := json.Marshal(r.last) |
| + q.Set("peer", string(peer)) |
| + q.Set("last", string(b)) |
| + } |
| + r.mu.Unlock() |
| + began := time.Now() |
| + streamCtx, streamCancel := context.WithTimeout(r.ctx, 6*time.Minute) |
| + res, err := r.t.request(streamCtx, http.MethodGet, "stream", q, nil) |
| + if err == nil { |
| + err = readEvents(res.Body, r.receive) |
| + res.Body.Close() |
| + } |
| + streamCancel() |
| + if r.ctx.Err() != nil { |
| + return |
| + } |
| + var refusal *HTTPError |
| + if errors.As(err, &refusal) { |
| + switch refusal.Status { |
| + case 410: |
| + reason := refusal.Reason |
| + if reason == "" { |
| + reason = "expired" |
| + } |
| + r.terminal(reason) |
| + return |
| + case 409: |
| + r.terminal("replaced") |
| + return |
| + case 401, 403, 302, 303, 307, 308: |
| + r.terminal("signin") |
| + return |
| + case 404: |
| + if peer != "" { |
| + r.fresh() |
| + delay = 500 * time.Millisecond |
| + continue |
| + } |
| + r.terminal("unavailable") |
| + return |
| + case 400, 406: |
| + r.terminal("protocol") |
| + return |
| + } |
| + } |
| + r.mu.Lock() |
| + r.state.Phase = "reconnecting" |
| + r.mu.Unlock() |
| + r.publish() |
| + if time.Since(began) > 5*time.Second { |
| + delay = 500 * time.Millisecond |
| + } |
| + if !pause(r.ctx, delay) { |
| + return |
| + } |
| + delay = min(delay*2, 10*time.Second) |
| + } |
| +} |
| +func pause(ctx context.Context, d time.Duration) bool { |
| + timer := time.NewTimer(d) |
| + defer timer.Stop() |
| + select { |
| + case <-ctx.Done(): |
| + return false |
| + case <-timer.C: |
| + return true |
| + } |
| +} |
| +func (r *Room) generationLocked(peer protocol.PeerID) uint64 { |
| + for _, p := range r.state.Peers { |
| + if p.Peer == peer { |
| + return p.Gen |
| + } |
| + } |
| + return 0 |
| +} |
| +func targeted(typ string) bool { |
| + return typ == protocol.TypeOffer || typ == protocol.TypeAnswer || typ == protocol.TypeCandidate |
| +} |
| + |
| +func (r *Room) receive(m protocol.Message) error { |
| + r.mu.Lock() |
| + if r.stopped { |
| + r.mu.Unlock() |
| + return context.Canceled |
| + } |
| + join, ice, reset := false, false, protocol.PeerID("") |
| + switch m.Type { |
| + case protocol.TypeHello: |
| + join = r.state.Peer == "" |
| + r.state.Peer = m.Peer |
| + r.state.Boot = m.Boot |
| + if join { |
| + r.state.Phase = "joining" |
| + } |
| + case protocol.TypeRoster: |
| + if m.Roster == nil { |
| + r.mu.Unlock() |
| + return errors.New("missing roster") |
| + } |
| + for _, p := range m.Roster.Peers { |
| + if p.Gen > r.generationLocked(p.Peer) { |
| + delete(r.blocked, p.Peer) |
| + } |
| + } |
| + for _, p := range r.state.Peers { |
| + present := false |
| + for _, next := range m.Roster.Peers { |
| + if next.Peer == p.Peer { |
| + present = true |
| + } |
| + } |
| + if !present { |
| + r.dropLaneLocked(p.Peer) |
| + } |
| + } |
| + r.state.Peers = append([]protocol.RosterPeer{}, m.Roster.Peers...) |
| + r.state.Waiting = append([]protocol.RosterPeer{}, m.Roster.Waiting...) |
| + r.state.Phase = "admitted" |
| + ice = true |
| + case protocol.TypePeer: |
| + if m.Roster != nil { |
| + for _, p := range m.Roster.Peers { |
| + r.upsertLocked(p) |
| + } |
| + } |
| + case protocol.TypeGone: |
| + r.state.Peers = without(r.state.Peers, m.From) |
| + r.dropLaneLocked(m.From) |
| + case protocol.TypeKnock: |
| + r.state.Waiting = without(r.state.Waiting, m.From) |
| + r.state.Waiting = append(r.state.Waiting, protocol.RosterPeer{Peer: m.From, Name: m.Name, Kind: "guest"}) |
| + case protocol.TypeKnockGone: |
| + r.state.Waiting = without(r.state.Waiting, m.From) |
| + case protocol.TypeState: |
| + if m.Waiting { |
| + r.state.Phase = "waiting" |
| + } |
| + case protocol.TypeFull: |
| + if r.state.Phase != "admitted" { |
| + r.state.Phase = "full" |
| + } |
| + case protocol.TypePair: |
| + for i, p := range r.state.Peers { |
| + if p.Peer == m.Peer && m.Gen > p.Gen { |
| + r.state.Peers[i].Gen = m.Gen |
| + delete(r.blocked, m.Peer) |
| + } |
| + } |
| + case protocol.TypeGap: |
| + reset = m.From |
| + case protocol.TypeLost: |
| + r.mu.Unlock() |
| + if m.Reason == protocol.ReasonBuffer { |
| + return errors.New("resume required") |
| + } |
| + r.terminal(string(m.Reason)) |
| + return context.Canceled |
| + default: |
| + if targeted(m.Type) { |
| + gen := r.generationLocked(m.From) |
| + if m.Seq == 0 || m.Seq <= r.last[m.From] { |
| + r.mu.Unlock() |
| + return nil |
| + } |
| + r.last[m.From] = m.Seq |
| + if gen == 0 || m.Gen < gen { |
| + r.mu.Unlock() |
| + return nil |
| + } |
| + if m.Gen > gen { |
| + for i, p := range r.state.Peers { |
| + if p.Peer == m.From { |
| + r.state.Peers[i].Gen = m.Gen |
| + } |
| + } |
| + } |
| + } |
| + } |
| + r.mu.Unlock() |
| + r.publish() |
| + r.emit(Event{Type: "message", Message: &m}) |
| + if join { |
| + if err := r.Send(protocol.TypeJoin, "", 0, ""); err != nil { |
| + return err |
| + } |
| + } |
| + if ice { |
| + select { |
| + case r.iceWake <- struct{}{}: |
| + default: |
| + } |
| + } |
| + if reset != "" { |
| + _ = r.Send(protocol.TypeReset, string(reset), 0, "") |
| + } |
| + return nil |
| +} |
| +func without(peers []protocol.RosterPeer, id protocol.PeerID) []protocol.RosterPeer { |
| + out := make([]protocol.RosterPeer, 0, len(peers)) |
| + for _, p := range peers { |
| + if p.Peer != id { |
| + out = append(out, p) |
| + } |
| + } |
| + return out |
| +} |
| +func (r *Room) upsertLocked(p protocol.RosterPeer) { |
| + r.state.Peers = without(r.state.Peers, p.Peer) |
| + r.state.Peers = append(r.state.Peers, p) |
| + sort.Slice(r.state.Peers, func(i, j int) bool { return r.state.Peers[i].Peer < r.state.Peers[j].Peer }) |
| +} |
| + |
| +// Send enqueues without blocking the UI. expectedGen prevents a late native |
| +// media callback from being relabelled as part of a replacement connection. |
| +func (r *Room) Send(typ, to string, expectedGen uint64, payload string) error { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + if r.stopped || !r.started || r.state.Peer == "" { |
| + return errors.New("room is not connected") |
| + } |
| + switch typ { |
| + case protocol.TypeJoin, protocol.TypeAdmit, protocol.TypeReset, protocol.TypeChat, protocol.TypeShare, protocol.TypeOffer, protocol.TypeAnswer, protocol.TypeCandidate: |
| + default: |
| + return errors.New("unsupported room command") |
| + } |
| + target := protocol.PeerID(to) |
| + s := protocol.Signal{Peer: r.state.Peer, Type: typ, To: target} |
| + if payload != "" { |
| + if !json.Valid([]byte(payload)) { |
| + return errors.New("invalid signal payload") |
| + } |
| + s.Payload = json.RawMessage(payload) |
| + } |
| + if targeted(typ) { |
| + gen := r.generationLocked(target) |
| + if target == r.state.Peer || gen == 0 || gen != expectedGen || r.blocked[target] { |
| + return errors.New("peer connection is no longer current") |
| + } |
| + s.Gen = gen |
| + s.Seq = r.seq[target] + 1 |
| + } |
| + b, err := json.Marshal(s) |
| + if err != nil || len(b) > 64*1024 { |
| + return errors.New("signal exceeds Meet's message limit") |
| + } |
| + laneKey := target |
| + if typ == protocol.TypeReset { |
| + if r.generationLocked(target) == 0 { |
| + return errors.New("peer is no longer present") |
| + } |
| + laneKey = "" |
| + } |
| + l := r.lanes[laneKey] |
| + if l == nil { |
| + if len(r.lanes) >= 17 { |
| + return errors.New("too many signaling destinations") |
| + } |
| + ctx, cancel := context.WithCancel(r.ctx) |
| + l = &lane{queue: make(chan protocol.Signal, 64), ctx: ctx, cancel: cancel} |
| + r.lanes[laneKey] = l |
| + r.wg.Add(1) |
| + go func() { defer r.wg.Done(); r.sendLoop(l) }() |
| + } |
| + select { |
| + case l.queue <- s: |
| + if targeted(typ) { |
| + r.seq[target] = s.Seq |
| + } |
| + return nil |
| + default: |
| + return errors.New("signaling queue is full") |
| + } |
| +} |
| +func (r *Room) current(s protocol.Signal) bool { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return !r.stopped && s.Peer == r.state.Peer && (!targeted(s.Type) || (r.generationLocked(s.To) == s.Gen && !r.blocked[s.To])) |
| +} |
| +func (r *Room) sendLoop(l *lane) { |
| + for { |
| + select { |
| + case <-l.ctx.Done(): |
| + return |
| + case s := <-l.queue: |
| + ctx, cancel := context.WithTimeout(l.ctx, 30*time.Second) |
| + delay := 500 * time.Millisecond |
| + for r.current(s) { |
| + err := r.t.post(ctx, s) |
| + if err == nil { |
| + break |
| + } |
| + var refusal *HTTPError |
| + if errors.As(err, &refusal) { |
| + if refusal.Status == 429 { |
| + delay = max(delay, refusal.RetryAfter) |
| + } else if refusal.Status < 500 { |
| + if refusal.Status == 401 || refusal.Status == 403 || refusal.Status == 302 || refusal.Status == 303 { |
| + r.terminal("signin") |
| + } |
| + if refusal.Status == 409 && targeted(s.Type) { |
| + r.resetAfterFailure(s) |
| + } |
| + break |
| + } |
| + } |
| + if !pause(ctx, delay) { |
| + if l.ctx.Err() == nil && targeted(s.Type) { |
| + r.resetAfterFailure(s) |
| + } |
| + break |
| + } |
| + delay = min(delay*2, 5*time.Second) |
| + } |
| + cancel() |
| + } |
| + } |
| +} |
| +func (r *Room) iceLoop() { |
| + var timer *time.Timer |
| + var tick <-chan time.Time |
| + defer func() { |
| + if timer != nil { |
| + timer.Stop() |
| + } |
| + }() |
| + for { |
| + select { |
| + case <-r.ctx.Done(): |
| + return |
| + case <-r.iceWake: |
| + case <-tick: |
| + } |
| + s := r.Snapshot() |
| + if s.Phase != "admitted" { |
| + continue |
| + } |
| + ice, err := r.t.ice(r.ctx, s.Peer) |
| + next := time.Minute |
| + if err == nil { |
| + if r.Snapshot().Peer != s.Peer { |
| + continue |
| + } |
| + r.emit(Event{Type: "ice", ICE: &ice}) |
| + if ice.RenewAt == 0 { |
| + tick = nil |
| + if timer != nil { |
| + timer.Stop() |
| + } |
| + continue |
| + } |
| + next = max(time.Until(time.Unix(ice.RenewAt, 0)), time.Second) |
| + } else { |
| + r.emit(Event{Type: "ice-unavailable"}) |
| + } |
| + if timer == nil { |
| + timer = time.NewTimer(next) |
| + } else { |
| + timer.Reset(next) |
| + } |
| + tick = timer.C |
| + } |
| +} |
| + |
| +func (r *Room) dropLaneLocked(peer protocol.PeerID) { |
| + if l := r.lanes[peer]; l != nil { |
| + l.cancel() |
| + delete(r.lanes, peer) |
| + } |
| + delete(r.last, peer) |
| + delete(r.seq, peer) |
| + delete(r.blocked, peer) |
| +} |
| +func (r *Room) resetAfterFailure(s protocol.Signal) { |
| + r.mu.Lock() |
| + if r.stopped || r.state.Peer != s.Peer || r.generationLocked(s.To) != s.Gen { |
| + r.mu.Unlock() |
| + return |
| + } |
| + r.blocked[s.To] = true |
| + r.mu.Unlock() |
| + // The control lane has the same retry/deadline contract as other sends; |
| + // it can overtake the discarded old-generation target queue. |
| + _ = r.Send(protocol.TypeReset, string(s.To), 0, "") |
| +} |
diff --git a/meetcore/room_test.go b/meetcore/room_test.go| new file mode 100644 |
| index 0000000..1e4e76b |
| --- /dev/null |
| +++ b/meetcore/room_test.go |
| @@ -0,0 +1,195 @@ |
| +package meetcore |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/meet/protocol" |
| +) |
| + |
| +func TestResumeRetainsParticipationAndDedupe(t *testing.T) { |
| + var mu sync.Mutex |
| + opens, joins := 0, 0 |
| + messages := make(chan Event, 64) |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + switch { |
| + case req.URL.Path == "/m/room/ice": |
| + fmt.Fprint(w, `{"iceServers":[],"expiresAt":0,"renewAt":0}`) |
| + case req.URL.Path == "/m/room/signal": |
| + var s protocol.Signal |
| + _ = json.NewDecoder(req.Body).Decode(&s) |
| + mu.Lock() |
| + if s.Type == "join" { |
| + joins++ |
| + } |
| + mu.Unlock() |
| + w.WriteHeader(200) |
| + case req.URL.Path == "/m/room/stream": |
| + mu.Lock() |
| + opens++ |
| + n := opens |
| + mu.Unlock() |
| + if req.Header.Get("Accept") != "text/event-stream" { |
| + t.Error("missing SSE Accept") |
| + } |
| + if n == 2 && (req.URL.Query().Get("peer") != "self" || req.URL.Query().Get("last") != `{"other":1}`) { |
| + t.Error("resume lost peer or cursor") |
| + } |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + fmt.Fprint(w, "data: {\"type\":\"hello\",\"peer\":\"self\",\"boot\":\"boot\"}\n\n") |
| + fmt.Fprint(w, "data: {\"type\":\"roster\",\"roster\":{\"peers\":[{\"peer\":\"self\",\"kind\":\"member\"},{\"peer\":\"other\",\"gen\":1}]}}\n\n") |
| + fmt.Fprint(w, "data: {\"type\":\"offer\",\"from\":\"other\",\"seq\":1,\"gen\":1,\"payload\":{}}\n\n") |
| + w.(http.Flusher).Flush() |
| + if n == 1 { |
| + time.Sleep(50 * time.Millisecond) |
| + return |
| + } |
| + fmt.Fprint(w, "data: {\"type\":\"candidate\",\"from\":\"other\",\"seq\":2,\"gen\":1,\"payload\":{}}\n\n") |
| + w.(http.Flusher).Flush() |
| + <-req.Context().Done() |
| + default: |
| + w.WriteHeader(404) |
| + } |
| + })) |
| + defer server.Close() |
| + r, err := New(server.URL, "room", server.Client(), func(e Event) { messages <- e }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = r.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + timer := time.NewTimer(5 * time.Second) |
| + defer timer.Stop() |
| + offers := 0 |
| + for { |
| + select { |
| + case e := <-messages: |
| + if e.Message != nil { |
| + if e.Message.Type == "offer" { |
| + offers++ |
| + } |
| + if e.Message.Type == "candidate" { |
| + mu.Lock() |
| + j := joins |
| + mu.Unlock() |
| + if offers != 1 || j != 1 { |
| + t.Fatalf("offers=%d joins=%d", offers, j) |
| + } |
| + return |
| + } |
| + } |
| + case <-timer.C: |
| + t.Fatal("resume never delivered") |
| + } |
| + } |
| +} |
| +func TestGenerationFenceAndMonotonicSequence(t *testing.T) { |
| + received := make(chan protocol.Signal, 10) |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + var s protocol.Signal |
| + _ = json.NewDecoder(req.Body).Decode(&s) |
| + received <- s |
| + })) |
| + defer srv.Close() |
| + r, _ := New(srv.URL, "room", srv.Client(), func(Event) {}) |
| + // Seed a live participant without a stream to isolate outgoing generation |
| + // fencing, including callbacks that arrive after the native PC was replaced. |
| + r.started = true |
| + r.state.Peer = "self" |
| + r.state.Peers = []protocol.RosterPeer{{Peer: "other", Gen: 1}} |
| + defer r.Close() |
| + if err := r.Send("offer", "other", 1, `{}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + select { |
| + case s := <-received: |
| + if s.Seq != 1 { |
| + t.Fatal(s.Seq) |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("missing offer") |
| + } |
| + if err := r.receive(protocol.Message{Type: "pair", Peer: "other", Gen: 2}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.Send("candidate", "other", 1, `{}`); err == nil { |
| + t.Fatal("accepted callback from replaced PC") |
| + } |
| + if err := r.Send("offer", "other", 2, `{}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + select { |
| + case s := <-received: |
| + if s.Seq != 2 || s.Gen != 2 { |
| + t.Fatal(s) |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("missing new offer") |
| + } |
| +} |
| +func TestTerminalLossClearsMediaAndDoesNotRejoin(t *testing.T) { |
| + r, _ := New("https://meet.example.com", "room", http.DefaultClient, func(Event) {}) |
| + r.started = true |
| + r.state.Peer = "self" |
| + r.state.Peers = []protocol.RosterPeer{{Peer: "other", Gen: 1}} |
| + _ = r.receive(protocol.Message{Type: "lost", Reason: protocol.ReasonRemoved}) |
| + s := r.Snapshot() |
| + if s.Phase != "ended" || s.Reason != "removed" || len(s.Peers) != 0 { |
| + t.Fatal(s) |
| + } |
| + if err := r.Send("join", "", 0, ""); err == nil { |
| + t.Fatal("terminal participant rejoined") |
| + } |
| + r.Close() |
| +} |
| + |
| +func TestTargetQueueRetriesSameSequenceBeforeNextMessage(t *testing.T) { |
| + received := make(chan protocol.Signal, 8) |
| + attempts := 0 |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + var s protocol.Signal |
| + _ = json.NewDecoder(req.Body).Decode(&s) |
| + attempts++ |
| + received <- s |
| + if attempts == 1 { |
| + w.WriteHeader(503) |
| + return |
| + } |
| + if attempts == 2 { |
| + w.Header().Set("Retry-After", "1") |
| + w.WriteHeader(429) |
| + return |
| + } |
| + })) |
| + defer srv.Close() |
| + r, _ := New(srv.URL, "room", srv.Client(), func(Event) {}) |
| + r.started = true |
| + r.state.Peer = "self" |
| + r.state.Peers = []protocol.RosterPeer{{Peer: "other", Gen: 1}} |
| + defer r.Close() |
| + if err := r.Send("offer", "other", 1, `{}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.Send("candidate", "other", 1, `{}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + deadline := time.NewTimer(5 * time.Second) |
| + defer deadline.Stop() |
| + for i, want := range []uint64{1, 1, 1, 2} { |
| + select { |
| + case s := <-received: |
| + if s.Seq != want || (i < 3 && s.Type != "offer") || (i == 3 && s.Type != "candidate") { |
| + t.Fatalf("attempt %d: %+v", i, s) |
| + } |
| + case <-deadline.C: |
| + t.Fatal("ordered retry stalled") |
| + } |
| + } |
| +} |
diff --git a/meetcore/transport.go b/meetcore/transport.go| new file mode 100644 |
| index 0000000..d29e437 |
| --- /dev/null |
| +++ b/meetcore/transport.go |
| @@ -0,0 +1,213 @@ |
| +// Package meetcore is the shared, UI-free Go client for Meet. It owns signaling |
| +// and room state plus browser sign-in handoff; the host owns browser launch, |
| +// credential storage and media. |
| +package meetcore |
| + |
| +import ( |
| + "bufio" |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/meet/protocol" |
| +) |
| + |
| +const maxResponse = 128 * 1024 |
| + |
| +// HTTPError intentionally excludes response bodies and URLs: those may contain |
| +// session material, meeting names, or peer-supplied payloads. |
| +type HTTPError struct { |
| + Status int |
| + Reason string |
| + RetryAfter time.Duration |
| +} |
| + |
| +func (e *HTTPError) Error() string { return fmt.Sprintf("Meet request refused (%d)", e.Status) } |
| + |
| +type transport struct { |
| + origin string |
| + room string |
| + http *http.Client |
| +} |
| + |
| +func newTransport(origin, room string, client *http.Client) (*transport, error) { |
| + u, err := url.Parse(origin) |
| + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { |
| + return nil, errors.New("enter a Meet server origin") |
| + } |
| + if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1")) { |
| + return nil, errors.New("Meet requires HTTPS (HTTP is allowed only on loopback)") |
| + } |
| + if room == "" || len(room) > 32 || strings.IndexFunc(room, func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') }) >= 0 { |
| + return nil, errors.New("invalid meeting identifier") |
| + } |
| + if client == nil { |
| + return nil, errors.New("an authenticated HTTP client is required") |
| + } |
| + // Never follow a redirect with a credential or accidentally decode a sign-in |
| + // page as protocol JSON. The caller's transport and cookie jar are retained. |
| + safe := *client |
| + if safe.Transport == nil { |
| + transport := http.DefaultTransport.(*http.Transport).Clone() |
| + transport.ResponseHeaderTimeout = 15 * time.Second |
| + safe.Transport = transport |
| + } |
| + safe.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } |
| + safe.Timeout = 0 // streams live for five minutes; individual calls have deadlines |
| + return &transport{strings.TrimSuffix(origin, "/"), room, &safe}, nil |
| +} |
| + |
| +func (t *transport) request(ctx context.Context, method, endpoint string, q url.Values, body []byte) (*http.Response, error) { |
| + path := t.origin + "/m/" + t.room + "/" + endpoint |
| + if len(q) > 0 { |
| + path += "?" + q.Encode() |
| + } |
| + req, err := http.NewRequestWithContext(ctx, method, path, bytes.NewReader(body)) |
| + if err != nil { |
| + return nil, errors.New("cannot build Meet request") |
| + } |
| + req.Header.Set("Accept", "application/json") |
| + if endpoint == "stream" { |
| + req.Header.Set("Accept", "text/event-stream") |
| + } |
| + if method == http.MethodPost { |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", t.origin) |
| + } |
| + res, err := t.http.Do(req) |
| + if err != nil { |
| + return nil, errors.New("cannot reach Meet") |
| + } |
| + if res.StatusCode >= 200 && res.StatusCode < 300 { |
| + return res, nil |
| + } |
| + defer res.Body.Close() |
| + var b struct { |
| + Reason string `json:"reason"` |
| + } |
| + _ = json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&b) |
| + // Only protocol-defined terminal reasons may reach the UI. |
| + switch b.Reason { |
| + case "replaced", "removed", "expired", "standing", "ended": |
| + default: |
| + b.Reason = "" |
| + } |
| + retry := time.Second |
| + if n, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && n > 0 { |
| + retry = min(time.Duration(n)*time.Second, 30*time.Second) |
| + } |
| + return nil, &HTTPError{res.StatusCode, b.Reason, retry} |
| +} |
| + |
| +func (t *transport) post(ctx context.Context, s protocol.Signal) error { |
| + b, err := json.Marshal(s) |
| + if err != nil || len(b) > 64*1024 { |
| + return errors.New("signal exceeds Meet's message limit") |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| + res, err := t.request(ctx, http.MethodPost, "signal", nil, b) |
| + if err != nil { |
| + return err |
| + } |
| + defer res.Body.Close() |
| + _, err = io.Copy(io.Discard, io.LimitReader(res.Body, maxResponse)) |
| + return err |
| +} |
| + |
| +// readEvents bounds both individual lines and complete events, accepts SSE |
| +// comments and multiline data, and never dispatches an incomplete final event. |
| +func readEvents(r io.Reader, receive func(protocol.Message) error) error { |
| + scanner := bufio.NewScanner(r) |
| + scanner.Buffer(make([]byte, 4096), maxResponse) |
| + var data []string |
| + size := 0 |
| + for scanner.Scan() { |
| + line := scanner.Text() |
| + if line == "" { |
| + if len(data) > 0 { |
| + var m protocol.Message |
| + if err := json.Unmarshal([]byte(strings.Join(data, "\n")), &m); err != nil { |
| + return errors.New("invalid Meet event") |
| + } |
| + if err := receive(m); err != nil { |
| + return err |
| + } |
| + } |
| + data = nil |
| + size = 0 |
| + continue |
| + } |
| + if strings.HasPrefix(line, "data:") { |
| + value := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ") |
| + size += len(value) + 1 |
| + if size > maxResponse { |
| + return errors.New("Meet event exceeds limit") |
| + } |
| + data = append(data, value) |
| + } |
| + } |
| + if err := scanner.Err(); err != nil { |
| + return errors.New("Meet event stream interrupted") |
| + } |
| + return io.EOF |
| +} |
| + |
| +// ICEServers is the server's short-lived TURN credential response. It stays in |
| +// memory and must never be logged or persisted by a native host. |
| +type ICEServers struct { |
| + Servers []ICEServer `json:"iceServers"` |
| + ExpiresAt int64 `json:"expiresAt"` |
| + RenewAt int64 `json:"renewAt"` |
| +} |
| +type ICEServer struct { |
| + URLs []string `json:"urls"` |
| + Username string `json:"username,omitempty"` |
| + Credential string `json:"credential,omitempty"` |
| +} |
| + |
| +func (t *transport) ice(ctx context.Context, peer protocol.PeerID) (ICEServers, error) { |
| + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| + res, err := t.request(ctx, http.MethodGet, "ice", url.Values{"peer": {string(peer)}}, nil) |
| + if err != nil { |
| + return ICEServers{}, err |
| + } |
| + defer res.Body.Close() |
| + var ice ICEServers |
| + if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&ice); err != nil { |
| + return ice, errors.New("invalid Meet ICE response") |
| + } |
| + return ice, nil |
| +} |
| + |
| +// Info is the same metadata the server renders on the holding screen. |
| +type Info struct { |
| + Title string `json:"title"` |
| + Name string `json:"name"` |
| + Standing string `json:"standing"` |
| + Floor bool `json:"floor"` |
| +} |
| + |
| +func (t *transport) info(ctx context.Context) (Info, error) { |
| + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| + res, err := t.request(ctx, http.MethodGet, "info", nil, nil) |
| + if err != nil { |
| + return Info{}, err |
| + } |
| + defer res.Body.Close() |
| + var info Info |
| + if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&info); err != nil { |
| + return info, errors.New("invalid meeting information") |
| + } |
| + return info, nil |
| +} |
diff --git a/meetcore/transport_test.go b/meetcore/transport_test.go| new file mode 100644 |
| index 0000000..9c6b94a |
| --- /dev/null |
| +++ b/meetcore/transport_test.go |
| @@ -0,0 +1,62 @@ |
| +package meetcore |
| + |
| +import ( |
| + "errors" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/meet/protocol" |
| +) |
| + |
| +func TestSSEFramingAndBounds(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, input string |
| + want int |
| + bad bool |
| + }{ |
| + {"comments and multiline", ": heartbeat\r\nevent: hello\r\ndata: {\"type\":\"hello\",\r\ndata: \"peer\":\"abc\"}\r\n\r\n", 1, false}, |
| + {"incomplete event", "data: {\"type\":\"hello\"}\n", 0, false}, |
| + {"malformed", "data: {bad}\n\n", 0, true}, |
| + {"bounded line", "data: " + strings.Repeat("x", maxResponse) + "\n\n", 0, true}, |
| + {"bounded event", strings.Repeat("data: "+strings.Repeat("x", 1024)+"\n", 130) + "\n", 0, true}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + count := 0 |
| + err := readEvents(strings.NewReader(tc.input), func(m protocol.Message) error { count++; return nil }) |
| + if count != tc.want || (err != nil && !errors.Is(err, io.EOF)) != tc.bad { |
| + t.Fatalf("events=%d err=%v", count, err) |
| + } |
| + }) |
| + } |
| +} |
| +func TestTransportNeverFollowsRedirectWithSession(t *testing.T) { |
| + reached := false |
| + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) |
| + defer foreign.Close() |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, foreign.URL, 307) })) |
| + defer server.Close() |
| + tr, err := newTransport(server.URL, "room", server.Client()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = tr.request(t.Context(), http.MethodGet, "ice", nil, nil) |
| + var refusal *HTTPError |
| + if !errors.As(err, &refusal) || refusal.Status != 307 || reached { |
| + t.Fatalf("redirect escaped: %v reached=%v", err, reached) |
| + } |
| +} |
| +func TestTransportRejectsUntrustedOriginsAndPaths(t *testing.T) { |
| + for _, origin := range []string{"http://meet.example.com", "https://user:pass@meet.example.com", "https://meet.example.com/path", "https://meet.example.com?token=secret", "https://meet.example.com#fragment", "file:///tmp/a"} { |
| + if _, err := newTransport(origin, "room", http.DefaultClient); err == nil { |
| + t.Fatalf("accepted %s", origin) |
| + } |
| + } |
| + for _, room := range []string{"../other", "a/b", "a?b", "", "a%2fb"} { |
| + if _, err := newTransport("https://meet.example.com", room, http.DefaultClient); err == nil { |
| + t.Fatalf("accepted room %q", room) |
| + } |
| + } |
| +} |
diff --git a/native/README.md b/native/README.md| new file mode 100644 |
| index 0000000..9b4db88 |
| --- /dev/null |
| +++ b/native/README.md |
| @@ -0,0 +1,81 @@ |
| +# Meet for Mac — first native milestone |
| + |
| +This work starts at `eee686a` (`integrate/home-sso-release`), which integrates |
| +Home assertions with the current call and agent API. `origin/main` was still |
| +`a283c53` when fetched. The native app is a SwiftUI client of the same server. |
| + |
| +## Build and run |
| + |
| +Requires macOS 14+, Xcode, Go 1.26 and network access for pinned dependencies. |
| + |
| +```sh |
| +make native-mac |
| +open native/build/Meet.app |
| +``` |
| + |
| +Paste a meeting or invitation URL. Meet opens your default browser for the |
| +existing Home/Meet sign-in. Open the emailed link normally, compare the code in |
| +the browser with the app, and confirm **Sign in to Meet app**. The app continues |
| +automatically; choose **Join meeting** and allow camera/microphone access. |
| +Meeting management stays on the web for this milestone. |
| + |
| +There are no embedded webviews. SwiftUI renders the app and native WebRTC handles |
| +media. The shared Go core waits for a five-minute, one-use authorization bound |
| +to a proof secret held only by the app. Browser approval creates a separate, |
| +ordinary framework session, preserving the existing identity method and Home |
| +assertion authentication time. No browser cookie is copied, and no new bearer |
| +authentication type is introduced. Credentials stay in memory for this milestone. |
| + |
| +**The server must include this branch's native authorization endpoints.** An |
| +older server cannot complete native sign-in; the app reports that limitation. |
| +No production deployment has been performed. In-progress handoffs expire on |
| +server restart. There are at most 256 pending handoffs per server process. |
| + |
| +`make native-mac` builds for the host Mac and signs the local `.app` ad hoc. |
| +Developer ID signing, notarization, App Store packaging and an iOS target are |
| +not part of this first milestone. |
| + |
| +## Shared code |
| + |
| +- `protocol/`: the server and client use the same Go wire types. The hub keeps |
| + aliases so authorization and admission behavior remain in the server. |
| +- `meetcore/`: browser sign-in handoff, bounded SSE parsing, room state, per-target ordered sends, |
| + generation fences, retry, resume and ICE credential fetching. No UI, database, |
| + or media dependency. |
| +- `native/bridge`: a narrow gomobile Objective-C interface consumed by Swift. |
| +- `native/apple/Sources/MeetKit`: Swift adapter and native WebRTC media. The |
| + platform capture/UI portions are separate from the Go protocol implementation. |
| +- `native/apple/Sources/MeetMac`: SwiftUI app and system-browser authentication host. |
| +- `native/apple/Sources/MeetInterop`: a separate synthetic-media test executable; |
| + it is not bundled with Meet.app. |
| + |
| +Go Mobile's cgo requirement is isolated in `native/go.mod` and the native build |
| +script. The existing server continues to build with `CGO_ENABLED=0`. WebRTC is |
| +pinned to stasel/WebRTC 152.0.0; SwiftPM verifies its published artifact checksum. |
| +Go Mobile is also pinned. Generated frameworks and binaries are ignored. |
| + |
| +## Verification |
| + |
| +```sh |
| +CGO_ENABLED=0 make ci |
| +make race |
| +CGO_ENABLED=0 make browser |
| +make native-test |
| +make native-browser |
| +CGO_ENABLED=0 go test -tags browser -run TestNativeBrowserAuthentication ./internal/meettest |
| +``` |
| + |
| +The native/browser gate requires the same Playwright/Chromium setup as |
| +`make browser`, and working UDP. It creates two real magic-link sessions and a |
| +meeting on an isolated server, starts Chromium and the separate native test |
| +executable, then checks guest admission, actual incoming/outgoing media, clean departure and continuity through |
| +three-second SSE renewals. It never needs production credentials or hardware |
| +camera access. The Go Home test separately exercises a session minted through |
| +Home assertions and explicit identity linking, including resume and meeting end. |
| + |
| +There is no claim that a simulator or synthetic test proves physical camera, |
| +microphone permissions, audio-device switching, sleep/wake or Home passkeys. |
| +Those need a real-device acceptance pass. Native screen sharing, background |
| +calling and long-call TURN renewal parity are follow-up work. |
| + |
| +See [VERIFICATION.md](VERIFICATION.md) for the completed gate record and acceptance limits. |
diff --git a/native/VERIFICATION.md b/native/VERIFICATION.md| new file mode 100644 |
| index 0000000..0506700 |
| --- /dev/null |
| +++ b/native/VERIFICATION.md |
| @@ -0,0 +1,58 @@ |
| +# Mac milestone verification — 2026-09-11 |
| + |
| +Base: fetched `origin/main` at `a283c53`, then fast-forwarded the working branch |
| +to the newer `origin/integrate/home-sso-release` at `eee686a`. Working branch: |
| +`codex/native-mac-milestone`. No production deployment was performed. |
| + |
| +| Check | Result | |
| +| --- | --- | |
| +| `CGO_ENABLED=0 make ci` | Passed: vet, formatting, all Go tests and migration agreement | |
| +| `make race` | Passed across `internal/...` | |
| +| `CGO_ENABLED=0 make browser` | Passed all 31 Chromium scenarios; 541 seconds for the browser suite | |
| +| `make native-test` | Passed shared core, bridge and core race tests | |
| +| `make native-mac` | Built the local Mac app with a macOS 14 deployment target | |
| +| `codesign --verify --deep --strict native/build/Meet.app` | Passed for the ad hoc development signature | |
| +| `TestNativeMacCallsBrowser` | Passed member and guest cases; final run 26.49 seconds | |
| +| App launch and opening-window inspection | Passed | |
| + |
| +The native/browser test observed video decoded in both directions, audio bytes |
| +received in both directions, no incoming guest media before admission, increasing |
| +media counters after repeated three-second stream renewals, preservation of the |
| +native participation ID, and removal of the browser's remote tile after the |
| +native client left. It uses a synthetic native camera implemented only in the |
| +separate `MeetInterop` executable, Chromium's fake media device, and real |
| +magic-link sessions on an isolated local Meet server. Audio traffic counters do |
| +not prove audible microphone/speaker quality. |
| + |
| +`TestNativeCoreUsesHomeSessionAndResumesCall` separately runs the shared Go |
| +client through the real Home assertion/linking fixture, stream renewal and |
| +meeting end. `TestNativeCoreCannotUseAgentBearerAsHumanSession` rejects agent |
| +credentials for calls. Metadata isolation tests cover an actual second meeting |
| +the guest cannot access. Unit tests exercise SSE bounds and framing, redirect |
| +refusal, retry sequence preservation, duplicate delivery and stale native |
| +callbacks after a pair generation changes. |
| + |
| +Unverified physical-device acceptance: camera/microphone permission prompts, |
| +audio-device changes, sleep/wake, and a live Home passkey ceremony. |
| +Distribution signing/notarization, |
| +iOS, screen sharing and full long-call TURN renewal parity remain outside this |
| +first milestone. The result is a local development app, not a public release. |
| + |
| + |
| +The webview sign-in implementation was replaced with system-browser approval. |
| +`TestNativeBrowserAuthentication` covers member and guest sign-in in a fresh |
| +Chromium context with JavaScript disabled: the email opens in a separate tab, |
| +approval hands a session to the shared Go client, and that session can read the |
| +meeting metadata. Server tests cover Home identity/authentication-time |
| +preservation, distinct session tokens, explicit POST approval, CSRF rejection, |
| +wrong proof, replay, expiration, revoked source sessions, and unsafe targets. |
| + |
| +Handoff revision checks (2026-09-11): CI and migration agreement passed; core |
| +and bridge tests passed with the core race detector; server authentication race |
| +tests passed; member/guest browser handoff and code comparison passed (65.13 s); |
| +native/browser media and departure checks passed (84.33 s). The rebuilt app's |
| +ad hoc signature verified, and the app was reopened locally. Production sign-in |
| +requires deployment of the new server routes; no deployment was performed. |
| + |
| +The full `CGO_ENABLED=0 make browser` gate also passed all 31 scenarios |
| +after the handoff change (797.94 s for Chromium). |
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved| new file mode 100644 |
| index 0000000..c68b7f5 |
| --- /dev/null |
| +++ b/native/apple/Package.resolved |
| @@ -0,0 +1,14 @@ |
| +{ |
| + "pins" : [ |
| + { |
| + "identity" : "webrtc", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/stasel/WebRTC.git", |
| + "state" : { |
| + "revision" : "1d04692697cb642bfebf6ad2dd99fe52649c3d6d", |
| + "version" : "152.0.0" |
| + } |
| + } |
| + ], |
| + "version" : 2 |
| +} |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| new file mode 100644 |
| index 0000000..1099984 |
| --- /dev/null |
| +++ b/native/apple/Package.swift |
| @@ -0,0 +1,21 @@ |
| +// swift-tools-version: 5.9 |
| +import PackageDescription |
| + |
| +let package = Package( |
| + name: "MeetNative", |
| + platforms: [.macOS(.v14)], |
| + products: [ |
| + .executable(name: "MeetMac", targets: ["MeetMac"]), |
| + .executable(name: "MeetInterop", targets: ["MeetInterop"]), |
| + ], |
| + dependencies: [ |
| + .package(url: "https://github.com/stasel/WebRTC.git", exact: "152.0.0") |
| + ], |
| + targets: [ |
| + .binaryTarget(name: "MeetBridge", path: "../build/MeetBridge.xcframework"), |
| + .target( |
| + name: "MeetKit", dependencies: ["MeetBridge", .product(name: "WebRTC", package: "WebRTC")]), |
| + .executableTarget(name: "MeetMac", dependencies: ["MeetKit"]), |
| + .executableTarget(name: "MeetInterop", dependencies: ["MeetKit"]), |
| + ] |
| +) |
diff --git a/native/apple/Sources/MeetInterop/Interop.swift b/native/apple/Sources/MeetInterop/Interop.swift| new file mode 100644 |
| index 0000000..6043735 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetInterop/Interop.swift |
| @@ -0,0 +1,44 @@ |
| +import Foundation |
| +import MeetKit |
| + |
| +// A separate test executable: synthetic video and fixture credentials are never |
| +// compiled into MeetMac. Input travels on stdin, not argv or a saved file. |
| +struct Input: Decodable { |
| + let origin: String |
| + let room: String |
| + let cookies: String |
| +} |
| +@main struct Interop { |
| + @MainActor static func main() async { |
| + guard let line = readLine(), |
| + let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)), |
| + let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1" |
| + else { exit(2) } |
| + let call = Call() |
| + do { |
| + try await call.join( |
| + origin: input.origin, room: input.room, cookies: input.cookies, capture: SyntheticVideo()) |
| + } catch { |
| + print("FAIL native start") |
| + exit(1) |
| + } |
| + Task.detached { |
| + if let command = readLine(), command == "leave" { |
| + await call.leave() |
| + exit(0) |
| + } |
| + } |
| + for _ in 0..<90 { |
| + try? await Task.sleep(nanoseconds: 1_000_000_000) |
| + let stats = await call.sampleStats() |
| + if let data = try? JSONSerialization.data(withJSONObject: [ |
| + "phase": call.state?.phase ?? "", "peer": call.state?.peer ?? "", "stats": stats, |
| + ]), let text = String(data: data, encoding: .utf8) { |
| + print(text) |
| + fflush(stdout) |
| + } |
| + } |
| + await call.leave() |
| + exit(0) |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetInterop/SyntheticVideo.swift b/native/apple/Sources/MeetInterop/SyntheticVideo.swift| new file mode 100644 |
| index 0000000..faa3307 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetInterop/SyntheticVideo.swift |
| @@ -0,0 +1,46 @@ |
| +import CoreVideo |
| +import Foundation |
| +import MeetKit |
| +import WebRTC |
| + |
| +@MainActor final class SyntheticVideo: VideoCapture { |
| + private var source: RTCVideoSource? |
| + private var syntheticCapturer: RTCVideoCapturer? |
| + private var task: Task<Void, Never>? |
| + func start(source: RTCVideoSource) async throws { |
| + self.source = source |
| + syntheticCapturer = RTCVideoCapturer(delegate: source) |
| + task = Task { @MainActor [weak self] in |
| + while !Task.isCancelled { |
| + self?.frame() |
| + try? await Task.sleep(nanoseconds: 66_666_667) |
| + } |
| + } |
| + } |
| + func stop() { |
| + task?.cancel() |
| + task = nil |
| + source = nil |
| + syntheticCapturer = nil |
| + } |
| + private func frame() { |
| + guard let source, let syntheticCapturer else { return } |
| + var pixel: CVPixelBuffer? |
| + CVPixelBufferCreate( |
| + kCFAllocatorDefault, 320, 240, kCVPixelFormatType_32BGRA, |
| + [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &pixel) |
| + guard let pixel else { return } |
| + CVPixelBufferLockBaseAddress(pixel, []) |
| + if let base = CVPixelBufferGetBaseAddress(pixel) { |
| + memset( |
| + base, Int32(Int(Date().timeIntervalSince1970 * 30) % 200 + 30), |
| + CVPixelBufferGetDataSize(pixel)) |
| + } |
| + CVPixelBufferUnlockBaseAddress(pixel, []) |
| + source.capturer( |
| + syntheticCapturer, |
| + didCapture: RTCVideoFrame( |
| + buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0, |
| + timeStampNs: Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000))) |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/Call.swift b/native/apple/Sources/MeetKit/Call.swift| new file mode 100644 |
| index 0000000..f4d07d8 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/Call.swift |
| @@ -0,0 +1,177 @@ |
| +import Combine |
| +import Foundation |
| +// Go serializes this API with a mutex; Objective-C bindings carry no Sendable annotation. |
| +@preconcurrency import MeetBridge |
| +import WebRTC |
| + |
| +// The Go callback only enqueues. The main actor owns the interface and media; |
| +// an epoch prevents callbacks from a closed room reaching a later call. |
| +private final class Events: NSObject, BridgeListenerProtocol { |
| + weak var call: Call? |
| + let epoch: UUID |
| + init(call: Call, epoch: UUID) { |
| + self.call = call |
| + self.epoch = epoch |
| + } |
| + func onEvent(_ event: String?) { |
| + guard let event, let data = event.data(using: .utf8), |
| + let decoded = try? JSONDecoder().decode(WireEvent.self, from: data) |
| + else { return } |
| + DispatchQueue.main.async { [weak self] in |
| + guard let self, let call = self.call, call.epoch == self.epoch else { return } |
| + call.receive(decoded) |
| + } |
| + } |
| +} |
| + |
| +@MainActor public final class Call: ObservableObject { |
| + @Published public private(set) var state: RoomState? |
| + @Published public private(set) var title = "Meet" |
| + @Published public private(set) var standing = "" |
| + @Published public private(set) var status = "Ready to join" |
| + @Published public private(set) var remoteTracks: [String: RTCVideoTrack] = [:] |
| + @Published public private(set) var localTrack: RTCVideoTrack? |
| + @Published public private(set) var muted = false |
| + @Published public private(set) var cameraOff = false |
| + @Published public private(set) var floor = false |
| + @Published public private(set) var chat: [String] = [] |
| + fileprivate var epoch = UUID() |
| + private var bridge: BridgeClient? |
| + private var events: Events? |
| + private lazy var media = MediaEngine(call: self) |
| + private var ice: ICEConfig? |
| + private var pendingMedia: [Message] = [] |
| + |
| + public init() {} |
| + public func join(origin: String, room: String, cookies: String, capture: VideoCapture? = nil) |
| + async throws |
| + { |
| + if bridge != nil { await leave() } else { close() } |
| + epoch = UUID() |
| + let listener = Events(call: self, epoch: epoch) |
| + var error: NSError? |
| + guard let client = BridgeNewClient(origin, room, cookies, listener, &error) else { |
| + throw error ?? NSError(domain: "Meet", code: 1) |
| + } |
| + events = listener |
| + bridge = client |
| + let joiningEpoch = epoch |
| + muted = false |
| + cameraOff = false |
| + chat = [] |
| + title = "Meet" |
| + standing = "" |
| + try await media.prepare(capture: capture) |
| + guard epoch == joiningEpoch, bridge === client else { throw CancellationError() } |
| + localTrack = media.localVideo |
| + try client.start() |
| + } |
| + public func close() { |
| + epoch = UUID() |
| + let old = bridge |
| + bridge = nil |
| + events = nil |
| + media.close() |
| + remoteTracks = [:] |
| + localTrack = nil |
| + ice = nil |
| + state = nil |
| + pendingMedia = [] |
| + if let old { DispatchQueue.global().async { old.close() } } |
| + } |
| + public func leave() async { |
| + let old = bridge |
| + bridge = nil |
| + close() |
| + status = "You left the meeting" |
| + if let old { await Task.detached { old.leave() }.value } |
| + } |
| + public func mute() { |
| + muted.toggle() |
| + media.localAudio?.isEnabled = !muted |
| + } |
| + public func toggleCamera() { |
| + cameraOff.toggle() |
| + media.localVideo?.isEnabled = !cameraOff |
| + } |
| + public func retryJoin() { send(type: "join", to: "", gen: 0, payload: "") } |
| + public func admit(_ peer: String) { |
| + send(type: "admit", to: "", gen: 0, payload: json(["peer": peer]) ?? "") |
| + } |
| + public func sendChat(_ text: String) { |
| + guard !text.isEmpty, let payload = json(["text": text]), payload.utf8.count <= 2059 else { |
| + return |
| + } |
| + send(type: "chat", to: "", gen: 0, payload: payload) |
| + } |
| + func send(type: String, to: String, gen: Int64, payload: String) { |
| + do { try bridge?.send(type, to: to, generation: gen, payload: payload) } catch { |
| + status = "Could not send to the meeting. Try again." |
| + } |
| + } |
| + fileprivate func receive(_ event: WireEvent) { |
| + switch event.type { |
| + case "info": |
| + if let info = event.info { |
| + title = info.title |
| + standing = info.standing |
| + floor = info.floor |
| + } |
| + case "state": |
| + guard let state = event.state else { return } |
| + self.state = state |
| + switch state.phase { |
| + case "admitted": status = "In the meeting" |
| + case "waiting": status = "Waiting to be let in" |
| + case "full": status = "The meeting is full. Try joining again shortly." |
| + case "reconnecting": status = "Reconnecting…" |
| + case "ended": |
| + status = |
| + [ |
| + "left": "You left the meeting", "ended": "This meeting has ended", |
| + "replaced": "This meeting is open elsewhere", |
| + "removed": "You were removed from this meeting", |
| + "standing": "You no longer have access", "expired": "Your invitation has expired", |
| + "signin": "Sign in again to join", "unavailable": "This meeting is unavailable", |
| + ][state.reason ?? ""] ?? "The call has ended" |
| + media.close() |
| + remoteTracks = [:] |
| + localTrack = nil |
| + default: status = "Connecting…" |
| + } |
| + if state.peer.isEmpty { |
| + media.closePeers() |
| + remoteTracks = [:] |
| + ice = nil |
| + } |
| + if let ice { media.reconcile(state: state, ice: ice) } |
| + case "ice": |
| + guard let config = event.ice else { return } |
| + let renewing = ice != nil |
| + ice = config |
| + floor = config.iceServers.isEmpty |
| + if let state { media.reconcile(state: state, ice: config) } |
| + if renewing { media.updateICE(config) } |
| + let pending = pendingMedia |
| + pendingMedia = [] |
| + for message in pending { media.receive(message) } |
| + case "ice-unavailable": status = "Could not refresh the call connection. Retrying…" |
| + case "message": |
| + guard let message = event.message else { return } |
| + if message.type == "chat", let text = message.payload?.text { |
| + chat.append("\(message.name ?? "Participant"): \(text)") |
| + if chat.count > 200 { chat.removeFirst(chat.count - 200) } |
| + } |
| + if ice == nil, ["offer", "answer", "candidate"].contains(message.type) { |
| + if pendingMedia.count < 256 { pendingMedia.append(message) } |
| + } else { |
| + media.receive(message) |
| + } |
| + default: break |
| + } |
| + } |
| + func setRemote(_ track: RTCVideoTrack, peer: String) { remoteTracks[peer] = track } |
| + func removeRemote(_ peer: String) { remoteTracks.removeValue(forKey: peer) } |
| + func mediaFailed() { status = "A participant’s media could not connect." } |
| + public func sampleStats() async -> [String: UInt64] { await media.stats() } |
| +} |
diff --git a/native/apple/Sources/MeetKit/Capture.swift b/native/apple/Sources/MeetKit/Capture.swift| new file mode 100644 |
| index 0000000..f1b5323 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/Capture.swift |
| @@ -0,0 +1,60 @@ |
| +import AVFoundation |
| +import Foundation |
| +import WebRTC |
| + |
| +// Capture is injected separately from signaling and rendering. App targets |
| +// provide the platform camera; the interop executable provides synthetic frames. |
| +@MainActor public protocol VideoCapture: AnyObject { |
| + func start(source: RTCVideoSource) async throws |
| + func stop() |
| +} |
| + |
| +@MainActor final class CameraCapture: VideoCapture { |
| + private var capturer: RTCCameraVideoCapturer? |
| + private var epoch = UUID() |
| + func start(source: RTCVideoSource) async throws { |
| + let preparing = epoch |
| + for kind in [AVMediaType.video, .audio] { |
| + let granted = await AVCaptureDevice.requestAccess(for: kind) |
| + guard preparing == epoch else { throw CancellationError() } |
| + if !granted { |
| + throw NSError( |
| + domain: "Meet", code: 2, |
| + userInfo: [ |
| + NSLocalizedDescriptionKey: |
| + "Allow camera and microphone access in System Settings to join." |
| + ]) |
| + } |
| + } |
| + guard let device = RTCCameraVideoCapturer.captureDevices().first, |
| + let format = RTCCameraVideoCapturer.supportedFormats(for: device).min(by: { |
| + abs(Int(CMVideoFormatDescriptionGetDimensions($0.formatDescription).width) - 640) |
| + < abs(Int(CMVideoFormatDescriptionGetDimensions($1.formatDescription).width) - 640) |
| + }) |
| + else { |
| + throw NSError( |
| + domain: "Meet", code: 3, userInfo: [NSLocalizedDescriptionKey: "No camera is available."]) |
| + } |
| + let capture = RTCCameraVideoCapturer(delegate: source) |
| + capturer = capture |
| + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in |
| + capture.startCapture( |
| + with: device, format: format, |
| + fps: max( |
| + 1, min(30, Int(format.videoSupportedFrameRateRanges.map { $0.maxFrameRate }.max() ?? 30))) |
| + ) { error in |
| + if let error { continuation.resume(throwing: error) } else { continuation.resume() } |
| + } |
| + } |
| + if preparing != epoch { |
| + await capture.stopCapture() |
| + throw CancellationError() |
| + } |
| + } |
| + |
| + func stop() { |
| + epoch = UUID() |
| + capturer?.stopCapture() |
| + capturer = nil |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/Media.swift b/native/apple/Sources/MeetKit/Media.swift| new file mode 100644 |
| index 0000000..d662927 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/Media.swift |
| @@ -0,0 +1,312 @@ |
| +import AVFoundation |
| +import CoreVideo |
| +import Foundation |
| +import WebRTC |
| + |
| +@MainActor final class MediaEngine { |
| + weak var call: Call? |
| + let factory: RTCPeerConnectionFactory |
| + var localVideo: RTCVideoTrack? |
| + var localAudio: RTCAudioTrack? |
| + private var capture: VideoCapture? |
| + private var peers: [String: MediaPeer] = [:] |
| + |
| + init(call: Call) { |
| + self.call = call |
| + RTCInitializeSSL() |
| + factory = RTCPeerConnectionFactory( |
| + encoderFactory: RTCDefaultVideoEncoderFactory(), |
| + decoderFactory: RTCDefaultVideoDecoderFactory()) |
| + } |
| + func prepare(capture supplied: VideoCapture?) async throws { |
| + close() |
| + let source = factory.videoSource() |
| + localVideo = factory.videoTrack(with: source, trackId: "camera") |
| + localAudio = factory.audioTrack(withTrackId: "microphone") |
| + let capture = supplied ?? CameraCapture() |
| + self.capture = capture |
| + try await capture.start(source: source) |
| + } |
| + func closePeers() { |
| + for (_, p) in peers { p.close() } |
| + peers.removeAll() |
| + } |
| + func close() { |
| + closePeers() |
| + capture?.stop() |
| + capture = nil |
| + localVideo?.isEnabled = false |
| + localAudio?.isEnabled = false |
| + localVideo = nil |
| + localAudio = nil |
| + } |
| + func reconcile(state: RoomState, ice: ICEConfig) { |
| + let wanted = Set(state.peers.filter { $0.peer != state.peer }.map(\.peer)) |
| + for id in Array(peers.keys) where !wanted.contains(id) { |
| + peers.removeValue(forKey: id)?.close() |
| + call?.removeRemote(id) |
| + } |
| + for person in state.peers where person.peer != state.peer { |
| + let gen = person.gen ?? 1 |
| + if let old = peers[person.peer], old.generation == gen { continue } |
| + peers.removeValue(forKey: person.peer)?.close() |
| + call?.removeRemote(person.peer) |
| + let peer = MediaPeer( |
| + id: person.peer, generation: gen, polite: state.peer < person.peer, engine: self) |
| + peers[person.peer] = peer |
| + peer.open(ice: ice) |
| + } |
| + } |
| + func receive(_ message: Message) { if let from = message.from { peers[from]?.receive(message) } } |
| + func updateICE(_ config: ICEConfig) { for p in peers.values { p.updateICE(config) } } |
| + func stats() async -> [String: UInt64] { |
| + var values: [String: UInt64] = [ |
| + "framesDecoded": 0, "audioBytesReceived": 0, "videoBytesSent": 0, "audioBytesSent": 0, |
| + ] |
| + for peer in peers.values { |
| + guard let pc = peer.pc else { continue } |
| + let report: RTCStatisticsReport = await withCheckedContinuation { c in |
| + pc.statistics { c.resume(returning: $0) } |
| + } |
| + for stat in report.statistics.values { |
| + let kind = (stat.values["kind"] as? String) ?? (stat.values["mediaType"] as? String) ?? "" |
| + if stat.type == "inbound-rtp" { |
| + if kind == "video" { |
| + values["framesDecoded", default: 0] += |
| + (stat.values["framesDecoded"] as? NSNumber)?.uint64Value ?? 0 |
| + } |
| + if kind == "audio" { |
| + values["audioBytesReceived", default: 0] += |
| + (stat.values["bytesReceived"] as? NSNumber)?.uint64Value ?? 0 |
| + } |
| + } |
| + if stat.type == "outbound-rtp" { |
| + if kind == "video" { |
| + values["videoBytesSent", default: 0] += |
| + (stat.values["bytesSent"] as? NSNumber)?.uint64Value ?? 0 |
| + } |
| + if kind == "audio" { |
| + values["audioBytesSent", default: 0] += |
| + (stat.values["bytesSent"] as? NSNumber)?.uint64Value ?? 0 |
| + } |
| + } |
| + } |
| + } |
| + return values |
| + } |
| +} |
| + |
| +@MainActor private final class MediaPeer: NSObject, RTCPeerConnectionDelegate { |
| + let id: String |
| + let generation: Int64 |
| + let polite: Bool |
| + weak var engine: MediaEngine? |
| + var pc: RTCPeerConnection? |
| + private var work: Task<Void, Never>? |
| + private var closed = false |
| + private var ignoreOffer = false |
| + private var candidates: [Candidate] = [] |
| + private var lastICE: ICEConfig? |
| + |
| + init(id: String, generation: Int64, polite: Bool, engine: MediaEngine) { |
| + self.id = id |
| + self.generation = generation |
| + self.polite = polite |
| + self.engine = engine |
| + } |
| + private func configuration(_ ice: ICEConfig) -> RTCConfiguration { |
| + let c = RTCConfiguration() |
| + c.sdpSemantics = .unifiedPlan |
| + c.iceServers = ice.iceServers.map { |
| + RTCIceServer(urlStrings: $0.urls, username: $0.username, credential: $0.credential) |
| + } |
| + return c |
| + } |
| + func open(ice: ICEConfig) { |
| + guard let engine else { return } |
| + lastICE = ice |
| + pc = engine.factory.peerConnection( |
| + with: configuration(ice), |
| + constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), |
| + delegate: self) |
| + if let audio = engine.localAudio { _ = pc?.add(audio, streamIds: ["meet"]) } |
| + if let video = engine.localVideo { _ = pc?.add(video, streamIds: ["meet"]) } |
| + } |
| + func close() { |
| + closed = true |
| + work?.cancel() |
| + work = nil |
| + pc?.close() |
| + pc = nil |
| + candidates = [] |
| + } |
| + private func enqueue(_ action: @escaping @MainActor () async throws -> Void) { |
| + let previous = work |
| + work = Task { [weak self] in |
| + await previous?.value |
| + guard let self, !self.closed, !Task.isCancelled else { return } |
| + do { try await action() } catch { if !self.closed { self.engine?.call?.mediaFailed() } } |
| + } |
| + } |
| + private func send(_ type: String, _ payload: [String: Any]) { |
| + guard !closed, let body = json(payload) else { return } |
| + engine?.call?.send(type: type, to: id, gen: generation, payload: body) |
| + } |
| + private func setLocal(_ desc: RTCSessionDescription) async throws { |
| + guard let pc, !closed else { return } |
| + try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| + pc.setLocalDescription(desc) { error in |
| + if let error { c.resume(throwing: error) } else { c.resume() } |
| + } |
| + } |
| + } |
| + private func setRemote(_ desc: RTCSessionDescription) async throws { |
| + guard let pc, !closed else { return } |
| + try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| + pc.setRemoteDescription(desc) { error in |
| + if let error { c.resume(throwing: error) } else { c.resume() } |
| + } |
| + } |
| + } |
| + private func makeDescription(answer: Bool, restart: Bool = false) async throws |
| + -> RTCSessionDescription |
| + { |
| + guard let pc else { throw CancellationError() } |
| + let constraints = RTCMediaConstraints( |
| + mandatoryConstraints: restart ? ["IceRestart": "true"] : nil, optionalConstraints: nil) |
| + return try await withCheckedThrowingContinuation { c in |
| + let completion: @Sendable (RTCSessionDescription?, Error?) -> Void = { desc, error in |
| + if let desc { |
| + c.resume(returning: desc) |
| + } else { |
| + c.resume(throwing: error ?? CancellationError()) |
| + } |
| + } |
| + if answer { |
| + pc.answer(for: constraints, completionHandler: completion) |
| + } else { |
| + pc.offer(for: constraints, completionHandler: completion) |
| + } |
| + } |
| + } |
| + private func offer(restart: Bool = false) async throws { |
| + guard let pc, !closed, pc.signalingState == .stable else { return } |
| + let desc = try await makeDescription(answer: false, restart: restart) |
| + guard !closed else { return } |
| + try await setLocal(desc) |
| + send("offer", ["description": ["type": "offer", "sdp": desc.sdp]]) |
| + } |
| + func receive(_ message: Message) { |
| + guard message.gen == generation else { return } |
| + enqueue { [weak self] in |
| + guard let self, let pc = self.pc, !self.closed else { return } |
| + if message.type == "candidate", let candidate = message.payload?.candidate { |
| + if self.ignoreOffer { return } |
| + if pc.remoteDescription == nil { |
| + guard self.candidates.count < 256 else { throw CancellationError() } |
| + self.candidates.append(candidate) |
| + } else { |
| + try await self.add(candidate) |
| + } |
| + return |
| + } |
| + guard let desc = message.payload?.description else { return } |
| + if message.type == "offer", desc.type == "offer" { |
| + let collision = pc.signalingState != .stable |
| + self.ignoreOffer = collision && !self.polite |
| + if self.ignoreOffer { return } |
| + if collision { try await self.setLocal(RTCSessionDescription(type: .rollback, sdp: "")) } |
| + try await self.setRemote(RTCSessionDescription(type: .offer, sdp: desc.sdp)) |
| + self.ignoreOffer = false |
| + try await self.drain() |
| + let answer = try await self.makeDescription(answer: true) |
| + try await self.setLocal(answer) |
| + self.send("answer", ["description": ["type": "answer", "sdp": answer.sdp]]) |
| + } else if message.type == "answer", desc.type == "answer", |
| + pc.signalingState == .haveLocalOffer |
| + { |
| + try await self.setRemote(RTCSessionDescription(type: .answer, sdp: desc.sdp)) |
| + self.ignoreOffer = false |
| + try await self.drain() |
| + } |
| + } |
| + } |
| + private func add(_ candidate: Candidate) async throws { |
| + guard let pc, !closed, !candidate.candidate.isEmpty else { return } |
| + let ice = RTCIceCandidate( |
| + sdp: candidate.candidate, sdpMLineIndex: candidate.sdpMLineIndex ?? 0, |
| + sdpMid: candidate.sdpMid) |
| + try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| + pc.add(ice) { error in if let error { c.resume(throwing: error) } else { c.resume() } } |
| + } |
| + } |
| + private func drain() async throws { |
| + let saved = candidates |
| + candidates = [] |
| + for c in saved { try await add(c) } |
| + } |
| + func updateICE(_ ice: ICEConfig) { |
| + guard ice.renewAt != lastICE?.renewAt else { return } |
| + lastICE = ice |
| + enqueue { [weak self] in |
| + guard let self, let pc = self.pc else { return } |
| + if pc.setConfiguration(self.configuration(ice)) { try await self.offer(restart: true) } |
| + } |
| + } |
| + nonisolated func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { |
| + Task { @MainActor [weak self] in |
| + guard let self else { return } |
| + // Give the higher-id peer first chance, matching the browser's |
| + // polite yield. Perfect negotiation still handles actual glare. |
| + if self.polite { try? await Task.sleep(nanoseconds: 200_000_000) } |
| + self.enqueue { [weak self] in try await self?.offer() } |
| + } |
| + } |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate |
| + ) { |
| + Task { @MainActor [weak self] in |
| + self?.send( |
| + "candidate", |
| + [ |
| + "candidate": [ |
| + "candidate": candidate.sdp, "sdpMid": candidate.sdpMid as Any? ?? NSNull(), |
| + "sdpMLineIndex": candidate.sdpMLineIndex, |
| + ] |
| + ]) |
| + } |
| + } |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didAdd rtpReceiver: RTCRtpReceiver, |
| + streams: [RTCMediaStream] |
| + ) { |
| + guard let track = rtpReceiver.track as? RTCVideoTrack else { return } |
| + Task { @MainActor [weak self] in |
| + guard let self, !self.closed else { return } |
| + self.engine?.call?.setRemote(track, peer: self.id) |
| + } |
| + } |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState |
| + ) { |
| + if newState == .failed { Task { @MainActor [weak self] in self?.engine?.call?.mediaFailed() } } |
| + } |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState |
| + ) {} |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream |
| + ) {} |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream |
| + ) {} |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState |
| + ) {} |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate] |
| + ) {} |
| + nonisolated func peerConnection( |
| + _ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel |
| + ) {} |
| +} |
diff --git a/native/apple/Sources/MeetKit/Wire.swift b/native/apple/Sources/MeetKit/Wire.swift| new file mode 100644 |
| index 0000000..eedae46 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/Wire.swift |
| @@ -0,0 +1,70 @@ |
| +import Foundation |
| + |
| +public struct RosterPeer: Decodable, Identifiable { |
| + public let peer: String |
| + public let name: String |
| + public let kind: String |
| + public let gen: Int64? |
| + public var id: String { peer } |
| +} |
| +public struct RoomState: Decodable { |
| + public let phase: String |
| + public let peer: String |
| + public let boot: String |
| + public let reason: String? |
| + public let peers: [RosterPeer] |
| + public let waiting: [RosterPeer] |
| +} |
| +struct WireEvent: Decodable { |
| + let type: String |
| + let state: RoomState? |
| + let message: Message? |
| + let ice: ICEConfig? |
| + let info: MeetingInfo? |
| +} |
| +struct Message: Decodable { |
| + let type: String |
| + let from: String? |
| + let peer: String? |
| + let name: String? |
| + let gen: Int64? |
| + let seq: UInt64? |
| + let payload: Payload? |
| +} |
| +struct Payload: Decodable { |
| + let description: Description? |
| + let candidate: Candidate? |
| + let text: String? |
| +} |
| +struct Description: Codable { |
| + let type: String |
| + let sdp: String |
| +} |
| +struct Candidate: Codable { |
| + let candidate: String |
| + let sdpMid: String? |
| + let sdpMLineIndex: Int32? |
| +} |
| +struct ICEConfig: Decodable { |
| + struct Server: Decodable { |
| + let urls: [String] |
| + let username: String? |
| + let credential: String? |
| + } |
| + let iceServers: [Server] |
| + let renewAt: Int64 |
| + let expiresAt: Int64 |
| +} |
| +func json(_ value: Any) -> String? { |
| + guard JSONSerialization.isValidJSONObject(value), |
| + let data = try? JSONSerialization.data(withJSONObject: value) |
| + else { return nil } |
| + return String(data: data, encoding: .utf8) |
| +} |
| + |
| +struct MeetingInfo: Decodable { |
| + let title: String |
| + let name: String |
| + let standing: String |
| + let floor: Bool |
| +} |
diff --git a/native/apple/Sources/MeetMac/MeetMac.swift b/native/apple/Sources/MeetMac/MeetMac.swift| new file mode 100644 |
| index 0000000..ef56e19 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetMac/MeetMac.swift |
| @@ -0,0 +1,261 @@ |
| +import AppKit |
| +@preconcurrency import MeetBridge |
| +import MeetKit |
| +import SwiftUI |
| +import WebRTC |
| + |
| +@main struct MeetMacApp: App { |
| + @StateObject private var model = AppModel() |
| + var body: some Scene { |
| + Window("Meet", id: "main") { |
| + ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| + } |
| + .commands { CommandGroup(replacing: .newItem) {} } |
| + } |
| +} |
| + |
| +@MainActor final class AppModel: ObservableObject { |
| + @Published var address = "" |
| + @Published var error = "" |
| + @Published var signin: URL? |
| + @Published var authenticating = false |
| + @Published var confirmationCode = "" |
| + private var authentication: BridgeAuthentication? |
| + private var authEpoch = UUID() |
| + @Published var signedIn = false |
| + @Published var joining = false |
| + @Published var room = "" |
| + @Published var origin = "" |
| + let call = Call() |
| + private var cookies = "" |
| + func connect() { |
| + cancelSignIn() |
| + authenticating = true |
| + error = "" |
| + let epoch = authEpoch |
| + let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) |
| + Task { |
| + do { |
| + let auth = try await Task.detached { |
| + var error: NSError? |
| + guard let auth = BridgeBeginAuthentication(address, &error) else { |
| + throw error ?? NSError(domain: "Meet", code: 1) |
| + } |
| + return auth |
| + }.value |
| + guard epoch == authEpoch else { |
| + auth.cancel() |
| + return |
| + } |
| + authentication = auth |
| + guard let url = URL(string: auth.browserURL()), NSWorkspace.shared.open(url) else { |
| + auth.cancel() |
| + throw NSError( |
| + domain: "Meet", code: 2, |
| + userInfo: [NSLocalizedDescriptionKey: "Could not open your browser."]) |
| + } |
| + 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 |
| + guard epoch == authEpoch else { return } |
| + let data = Data(result.utf8) |
| + let response = try JSONDecoder().decode(SignInResult.self, from: data) |
| + cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! |
| + origin = response.origin |
| + room = response.room |
| + signedIn = true |
| + authenticating = false |
| + signin = nil |
| + authentication = nil |
| + NSApplication.shared.activate(ignoringOtherApps: true) |
| + } catch { |
| + guard epoch == authEpoch else { return } |
| + self.error = error.localizedDescription |
| + cancelSignIn() |
| + } |
| + } |
| + } |
| + func cancelSignIn() { |
| + authEpoch = UUID() |
| + authentication?.cancel() |
| + authentication = nil |
| + authenticating = false |
| + confirmationCode = "" |
| + signin = nil |
| + } |
| + private struct SignInResult: Decodable { |
| + let origin: String |
| + let room: String |
| + let cookies: [Cookie] |
| + struct Cookie: Codable { |
| + let name: String |
| + let value: String |
| + } |
| + } |
| + func join() { |
| + joining = true |
| + Task { |
| + do { try await call.join(origin: origin, room: room, cookies: cookies) } catch { |
| + self.error = error.localizedDescription |
| + call.close() |
| + joining = false |
| + } |
| + } |
| + } |
| + func leave() { |
| + cancelSignIn() |
| + Task { |
| + await call.leave() |
| + joining = false |
| + } |
| + } |
| +} |
| + |
| +struct ContentView: View { |
| + @ObservedObject var model: AppModel |
| + var body: some View { |
| + VStack(alignment: .leading, spacing: 20) { |
| + if model.joining { |
| + CallView(call: model.call, leave: model.leave) |
| + } else if model.authenticating { |
| + Text("Finish signing in in your browser").font(.title2) |
| + Text( |
| + "Open the emailed link normally, then confirm “Sign in to Meet app”. Meet will continue here automatically." |
| + ).foregroundStyle(.secondary) |
| + ProgressView() |
| + if !model.confirmationCode.isEmpty { |
| + Text(model.confirmationCode).font(.title.monospaced()).textSelection(.enabled) |
| + Text("Check that your browser shows this same code before confirming.") |
| + } |
| + if let url = model.signin { |
| + Button("Open browser again") { NSWorkspace.shared.open(url) } |
| + } |
| + Button("Cancel", action: model.cancelSignIn) |
| + Spacer() |
| + } else if model.signedIn { |
| + Text("Ready to join").font(.largeTitle) |
| + Text("Your camera and microphone will be requested when you join.") |
| + Text("Meeting \(model.room)").foregroundStyle(.secondary) |
| + Button("Join meeting", action: model.join).buttonStyle(.borderedProminent) |
| + Button("Choose another meeting") { model.signedIn = false } |
| + } else { |
| + Text("Meet").font(.largeTitle.bold()) |
| + Text("Join your team’s meeting from your Mac.").foregroundStyle(.secondary) |
| + Text("Meeting link").font(.headline) |
| + TextField("Meeting or invitation link", text: $model.address).textFieldStyle(.roundedBorder) |
| + .onSubmit(model.connect) |
| + Button("Continue", action: model.connect).buttonStyle(.borderedProminent) |
| + Spacer() |
| + } |
| + if !model.error.isEmpty { |
| + Text(model.error).foregroundStyle(.red).accessibilityLabel("Error: \(model.error)") |
| + } |
| + }.padding(24) |
| + } |
| +} |
| +struct CallView: View { |
| + @ObservedObject var call: Call |
| + let leave: () -> Void |
| + @State private var message = "" |
| + var body: some View { |
| + VStack(alignment: .leading, spacing: 16) { |
| + HStack { |
| + Text(call.title).font(.title.bold()) |
| + Spacer() |
| + Text(call.status).accessibilityAddTraits(.updatesFrequently) |
| + } |
| + Text(call.standing.capitalized).font(.callout).foregroundStyle(.secondary) |
| + if call.floor { |
| + Text("Direct connections only. People on some networks may be unable to connect.").font( |
| + .callout |
| + ).foregroundStyle(.secondary) |
| + } |
| + ScrollView { |
| + LazyVGrid(columns: [GridItem(.adaptive(minimum: 260))], spacing: 16) { |
| + if let local = call.localTrack { VideoTile(track: local, label: "You") } |
| + ForEach(call.state?.peers.filter { $0.peer != call.state?.peer } ?? []) { peer in |
| + if let track = call.remoteTracks[peer.peer] { |
| + VideoTile(track: track, label: peer.name) |
| + } else { |
| + VStack { |
| + ProgressView() |
| + Text(peer.name) |
| + }.frame(height: 180) |
| + } |
| + } |
| + } |
| + } |
| + ForEach(call.state?.waiting ?? []) { peer in |
| + HStack { |
| + Text("\(peer.name) is waiting") |
| + Button("Let in") { call.admit(peer.peer) } |
| + } |
| + } |
| + if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| + HStack { |
| + Button(call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() } |
| + Button(call.cameraOff ? "Turn camera on" : "Turn camera off") { call.toggleCamera() } |
| + Spacer() |
| + Button("Leave meeting", role: .destructive, action: leave) |
| + } |
| + if call.state?.phase == "admitted" { |
| + ScrollView { |
| + VStack(alignment: .leading) { |
| + ForEach(Array(call.chat.enumerated()), id: \.offset) { _, line in |
| + Text(line).textSelection(.enabled) |
| + } |
| + }.frame(maxWidth: .infinity, alignment: .leading) |
| + }.frame(maxHeight: 90) |
| + HStack { |
| + TextField("Message to everyone", text: $message).onSubmit(send) |
| + Button("Send", action: send).disabled(message.isEmpty) |
| + } |
| + } |
| + } |
| + } |
| + private func send() { |
| + call.sendChat(message) |
| + message = "" |
| + } |
| +} |
| +struct VideoTile: View { |
| + let track: RTCVideoTrack |
| + let label: String |
| + var body: some View { |
| + VStack(alignment: .leading) { |
| + VideoView(track: track).frame(height: 200).background(.black).clipShape( |
| + RoundedRectangle(cornerRadius: 12)) |
| + Text(label).font(.headline) |
| + }.accessibilityElement(children: .combine) |
| + } |
| +} |
| +struct VideoView: NSViewRepresentable { |
| + let track: RTCVideoTrack |
| + func makeCoordinator() -> Coordinator { Coordinator() } |
| + func makeNSView(context: Context) -> RTCMTLNSVideoView { |
| + let view = RTCMTLNSVideoView(frame: .zero) |
| + context.coordinator.attach(track, to: view) |
| + return view |
| + } |
| + func updateNSView(_ view: RTCMTLNSVideoView, context: Context) { |
| + context.coordinator.attach(track, to: view) |
| + } |
| + static func dismantleNSView(_ view: RTCMTLNSVideoView, coordinator: Coordinator) { |
| + coordinator.track?.remove(view) |
| + } |
| + final class Coordinator { |
| + var track: RTCVideoTrack? |
| + func attach(_ next: RTCVideoTrack, to view: RTCMTLNSVideoView) { |
| + if track !== next { |
| + track?.remove(view) |
| + track = next |
| + next.add(view) |
| + } |
| + } |
| + } |
| +} |
diff --git a/native/architecture.md b/native/architecture.md| new file mode 100644 |
| index 0000000..91d4b31 |
| --- /dev/null |
| +++ b/native/architecture.md |
| @@ -0,0 +1,26 @@ |
| +`Call` and `MediaEngine` are main-actor owned. The Go listener dispatches in |
| +arrival order onto that actor. Each peer serializes asynchronous SDP operations; |
| +late callbacks check the peer's closed flag, and the Go sender checks generation |
| +before accepting a signal. Candidate queues are bounded and drained only after a |
| +remote description. A call epoch discards callbacks from an earlier room. |
| + |
| +`@preconcurrency import MeetBridge` describes an Objective-C boundary whose |
| +thread safety is supplied by the Go mutexes and tested with the race detector. |
| +It is not an assertion that WebRTC objects can be used from arbitrary threads. |
| + |
| + |
| +Native sign-in opens the default system browser. The shared Go client creates a |
| +random verifier, sends its SHA-256 challenge to `/native/request`, and polls |
| +`/native/exchange` with the verifier. `/native/start` keeps a short-lived pending |
| +cookie while the existing sign-in and invitation continuation run. The browser |
| +then shows the signed-in account, a code matching the app, and an explicit |
| +same-origin protected approval form. JavaScript is unnecessary. |
| + |
| +The server retains bounded requests in memory for five minutes. Exchange is |
| +single-use and revalidates the approving browser session and current identity |
| +before minting a separate normal framework session. Home subject, method and |
| +authentication time are preserved. A browser URL contains neither the verifier |
| +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 Meet session handoff, not an |
| +OAuth authorization server or a replacement for Home assertion verification. |
diff --git a/native/bridge/auth.go b/native/bridge/auth.go| new file mode 100644 |
| index 0000000..e6e2976 |
| --- /dev/null |
| +++ b/native/bridge/auth.go |
| @@ -0,0 +1,29 @@ |
| +package bridge |
| + |
| +import ( |
| + "amadan.net/oficina/meet/meetcore" |
| + "context" |
| + "encoding/json" |
| +) |
| + |
| +type Authentication struct{ auth *meetcore.Authentication } |
| + |
| +func BeginAuthentication(address string) (*Authentication, error) { |
| + a, e := meetcore.BeginAuthentication(context.Background(), address) |
| + 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) { |
| + r, e := a.auth.Wait() |
| + if e != nil { |
| + return "", e |
| + } |
| + b, e := json.Marshal(r) |
| + return string(b), e |
| +} |
| + |
| +func (a *Authentication) ConfirmationCode() string { return a.auth.ConfirmationCode() } |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| new file mode 100644 |
| index 0000000..47f3367 |
| --- /dev/null |
| +++ b/native/bridge/bridge.go |
| @@ -0,0 +1,78 @@ |
| +// Package bridge is the deliberately small gomobile surface. It imports only |
| +// Meet's client core, never the database, server, or at-rest encryption keys. |
| +package bridge |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/http/cookiejar" |
| + "net/url" |
| + |
| + "amadan.net/oficina/meet/meetcore" |
| +) |
| + |
| +// Listener receives JSON events on a Go callback thread. Enqueue on the main |
| +// actor before touching Apple UI or WebRTC. Never block or call Close inline. |
| +type Listener interface{ OnEvent(event string) } |
| + |
| +type Client struct{ room *meetcore.Room } |
| + |
| +// NewClient uses the ordinary session returned by the browser sign-in handoff. |
| +// This call does not mint, exchange, or accept an agent bearer token. |
| +// Cookies are scoped to the exact supplied origin and kept in memory only. |
| +func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, error) { |
| + if listener == nil { |
| + return nil, errors.New("a listener is required") |
| + } |
| + u, err := url.Parse(origin) |
| + if err != nil { |
| + return nil, errors.New("invalid Meet origin") |
| + } |
| + var cookies []struct { |
| + Name string `json:"name"` |
| + Value string `json:"value"` |
| + } |
| + if len(cookiesJSON) > 8192 || json.Unmarshal([]byte(cookiesJSON), &cookies) != nil { |
| + return nil, errors.New("invalid session cookies") |
| + } |
| + jar, _ := cookiejar.New(nil) |
| + session := false |
| + for _, c := range cookies { |
| + if c.Name != "__Host-rastrillo_session" && c.Name != "rastrillo_session" { |
| + continue |
| + } |
| + if session { |
| + return nil, errors.New("ambiguous Meet session") |
| + } |
| + raw, err := base64.RawURLEncoding.DecodeString(c.Value) |
| + if err != nil || len(raw) != 32 || base64.RawURLEncoding.EncodeToString(raw) != c.Value { |
| + return nil, errors.New("invalid Meet session") |
| + } |
| + session = true |
| + jar.SetCookies(u, []*http.Cookie{{Name: c.Name, Value: c.Value, Path: "/", Secure: u.Scheme == "https", HttpOnly: true}}) |
| + } |
| + if !session { |
| + return nil, errors.New("sign in to Meet first") |
| + } |
| + r, err := meetcore.New(origin, room, &http.Client{Jar: jar}, func(event meetcore.Event) { |
| + b, err := json.Marshal(event) |
| + if err == nil { |
| + listener.OnEvent(string(b)) |
| + } |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Client{room: r}, nil |
| +} |
| +func (c *Client) Start() error { return c.room.Start() } |
| +func (c *Client) Send(typ, to string, generation int64, payload string) error { |
| + if generation < 0 { |
| + return errors.New("invalid generation") |
| + } |
| + return c.room.Send(typ, to, uint64(generation), payload) |
| +} |
| +func (c *Client) Leave() { c.room.Leave() } |
| +func (c *Client) Close() { c.room.Close() } |
diff --git a/native/bridge/bridge_test.go b/native/bridge/bridge_test.go| new file mode 100644 |
| index 0000000..fc6de2d |
| --- /dev/null |
| +++ b/native/bridge/bridge_test.go |
| @@ -0,0 +1,31 @@ |
| +package bridge |
| + |
| +import ( |
| + "encoding/base64" |
| + "testing" |
| +) |
| + |
| +type discardEvents struct{} |
| + |
| +func (discardEvents) OnEvent(string) {} |
| +func TestBridgeRequiresHumanSessionAndSafeOrigin(t *testing.T) { |
| + token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| + cookie := `[{"name":"__Host-rastrillo_session","value":"` + token + `"}]` |
| + for _, tc := range []struct{ origin, cookies string }{ |
| + {"https://meet.example.com", `[]`}, |
| + {"https://meet.example.com", `[{"name":"Authorization","value":"Bearer mt_fake"}]`}, |
| + {"https://meet.example.com", `[{"name":"__Host-rastrillo_session","value":"bad"}]`}, |
| + {"http://meet.example.com", cookie}, |
| + {"https://meet.example.com/path", cookie}, |
| + } { |
| + if c, err := NewClient(tc.origin, "room", tc.cookies, discardEvents{}); err == nil { |
| + c.Close() |
| + t.Fatal("accepted invalid native session") |
| + } |
| + } |
| + c, err := NewClient("https://meet.example.com", "room", cookie, discardEvents{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c.Close() |
| +} |
diff --git a/native/go.mod b/native/go.mod| new file mode 100644 |
| index 0000000..2800cd4 |
| --- /dev/null |
| +++ b/native/go.mod |
| @@ -0,0 +1,19 @@ |
| +module amadan.net/oficina/meet/native |
| + |
| +go 1.26.0 |
| + |
| +require amadan.net/oficina/meet v0.0.0 |
| + |
| +require ( |
| + golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e // indirect |
| + golang.org/x/mod v0.41.0 // indirect |
| + golang.org/x/sync v0.23.0 // indirect |
| + golang.org/x/tools v0.50.0 // indirect |
| +) |
| + |
| +replace amadan.net/oficina/meet => .. |
| + |
| +tool ( |
| + golang.org/x/mobile/cmd/gobind |
| + golang.org/x/mobile/cmd/gomobile |
| +) |
diff --git a/native/go.sum b/native/go.sum| new file mode 100644 |
| index 0000000..1f88715 |
| --- /dev/null |
| +++ b/native/go.sum |
| @@ -0,0 +1,14 @@ |
| +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= |
| +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
| +golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e h1:zYnHsDmxfxKH/x9DjYNAdoIdMsP8enB1Ncb10b3pdHk= |
| +golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e/go.mod h1:+g6JKqLjAprgkdZKuuBhkhIN9zvSktraeSABDN2M7p8= |
| +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= |
| +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= |
| +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= |
| +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= |
| +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= |
| +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= |
| +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= |
| +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= |
| +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= |
| +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= |
diff --git a/native/scripts/build-bridge.sh b/native/scripts/build-bridge.sh| new file mode 100755 |
| index 0000000..89d463b |
| --- /dev/null |
| +++ b/native/scripts/build-bridge.sh |
| @@ -0,0 +1,7 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net" |
| +mkdir -p build/bin |
| +CGO_ENABLED=0 GOBIN="$PWD/build/bin" go install golang.org/x/mobile/cmd/gomobile golang.org/x/mobile/cmd/gobind |
| +PATH="$PWD/build/bin:$PATH" gomobile bind -target=macos -macosversion=14.0 -o build/MeetBridge.xcframework ./bridge |
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh| new file mode 100755 |
| index 0000000..34b731e |
| --- /dev/null |
| +++ b/native/scripts/build-mac.sh |
| @@ -0,0 +1,8 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +./scripts/build-bridge.sh |
| +swift build --package-path apple |
| +python3 scripts/bundle-mac.py |
| +codesign --force --deep --sign - build/Meet.app |
| +printf 'Built %s/build/Meet.app\n' "$PWD" |
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py| new file mode 100644 |
| index 0000000..5732405 |
| --- /dev/null |
| +++ b/native/scripts/bundle-mac.py |
| @@ -0,0 +1,36 @@ |
| +"""Bundle the local development app. Distribution signing is a separate step.""" |
| +from pathlib import Path |
| +import plistlib |
| +import shutil |
| +import subprocess |
| + |
| +root = Path(__file__).resolve().parent.parent |
| +bundle = root / 'build/Meet.app/Contents' |
| +(bundle / 'MacOS').mkdir(parents=True, exist_ok=True) |
| +(bundle / 'Frameworks').mkdir(parents=True, exist_ok=True) |
| +shutil.copy2(root / 'apple/.build/debug/MeetMac', bundle / 'MacOS/MeetMac') |
| +info = { |
| + 'CFBundleName': 'Meet', 'CFBundleDisplayName': 'Meet', |
| + 'CFBundleIdentifier': 'net.amadan.oficina.meet', |
| + 'CFBundleExecutable': 'MeetMac', 'CFBundlePackageType': 'APPL', |
| + '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.', |
| +} |
| +(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'))]: |
| + manifest = plistlib.loads((xc / 'Info.plist').read_bytes()) |
| + for lib in manifest['AvailableLibraries']: |
| + if lib['SupportedPlatform'] != 'macos': |
| + continue |
| + source = xc / lib['LibraryIdentifier'] / lib['LibraryPath'] |
| + binary = source / source.stem |
| + if 'dynamically linked' not in subprocess.check_output(['file', str(binary)], text=True): |
| + continue |
| + 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) |
diff --git a/protocol/auth.go b/protocol/auth.go| new file mode 100644 |
| index 0000000..146213d |
| --- /dev/null |
| +++ b/protocol/auth.go |
| @@ -0,0 +1,15 @@ |
| +package protocol |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "strings" |
| +) |
| + |
| +// AuthenticationCode lets a person compare the browser's authorization request |
| +// with the one they initiated in their app. It is a label, never a credential. |
| +func AuthenticationCode(id string) string { |
| + sum := sha256.Sum256([]byte(id)) |
| + code := strings.ToUpper(hex.EncodeToString(sum[:4])) |
| + return code[:4] + "-" + code[4:] |
| +} |
diff --git a/protocol/messages.go b/protocol/messages.go| new file mode 100644 |
| index 0000000..84a840a |
| --- /dev/null |
| +++ b/protocol/messages.go |
| @@ -0,0 +1,102 @@ |
| +// Package protocol defines Meet’s client-neutral signaling contract. |
| +package protocol |
| + |
| +import "encoding/json" |
| + |
| +// PeerID is the server-issued identity of one participation: sixteen |
| +// random bytes in hex, bound to a room and a session, shown in rosters |
| +// so browsers can address each other. It is not a credential (spec §4.2). |
| +type PeerID string |
| + |
| +// Kind is a participation's authority, re-derived from standing on |
| +// every tick (spec §2.4). |
| +type Kind int |
| + |
| +// Reason says why a stream ended. Only "buffer" is followed by a |
| +// resume; the rest are terminal (spec §4.5). |
| +type Reason string |
| + |
| +const ( |
| + KindGuest Kind = iota |
| + KindMember |
| +) |
| + |
| +func (k Kind) String() string { |
| + if k == KindMember { |
| + return "member" |
| + } |
| + return "guest" |
| +} |
| + |
| +const ( |
| + ReasonBuffer Reason = "buffer" |
| + ReasonReplaced Reason = "replaced" |
| + ReasonStanding Reason = "standing" |
| + ReasonExpired Reason = "expired" |
| + ReasonRemoved Reason = "removed" |
| + ReasonEnded Reason = "ended" |
| +) |
| + |
| +// Upstream types a browser may send. |
| +const ( |
| + TypeJoin = "join" |
| + TypeAdmit = "admit" |
| + TypeLeave = "leave" |
| + TypeOffer = "offer" |
| + TypeAnswer = "answer" |
| + TypeCandidate = "candidate" |
| + TypeReset = "reset" |
| + TypeChat = "chat" // slice 4 |
| + TypeShare = "share" // slice 4 |
| +) |
| + |
| +// Downstream types the server emits. |
| +const ( |
| + TypeHello = "hello" |
| + TypeRoster = "roster" |
| + TypePeer = "peer" |
| + TypeGone = "gone" |
| + TypeKnock = "knock" |
| + TypeKnockGone = "knock-gone" |
| + TypeFull = "full" |
| + // TypeEnded names the ending in §4.3's table, but nothing sends it: |
| + // a meeting that ends closes every stream, and a closing stream |
| + // carries its reason in lost, so the browser reads the ending as |
| + // lost{reason: "ended"} (§4.5). The name is kept because the spec |
| + // uses it. |
| + TypeEnded = "ended" |
| + TypeLost = "lost" |
| + TypePair = "pair" |
| + TypeGap = "gap" |
| + TypeState = "state" |
| +) |
| + |
| +// Message is the one envelope, built by the server. Payload is opaque. |
| +type Message struct { |
| + Type string `json:"type"` |
| + From PeerID `json:"from,omitempty"` |
| + Name string `json:"name,omitempty"` |
| + To PeerID `json:"to,omitempty"` |
| + Seq uint64 `json:"seq,omitempty"` |
| + Gen uint64 `json:"gen,omitempty"` |
| + Payload json.RawMessage `json:"payload,omitempty"` |
| + Reason Reason `json:"reason,omitempty"` |
| + Roster *Roster `json:"roster,omitempty"` |
| + Boot string `json:"boot,omitempty"` |
| + Peer PeerID `json:"peer,omitempty"` |
| + Waiting bool `json:"waiting,omitempty"` |
| +} |
| + |
| +// Roster is the authoritative snapshot a browser reconciles against |
| +// (spec §4.3). Waiting is filled only for a member recipient. |
| +type Roster struct { |
| + Peers []RosterPeer `json:"peers"` |
| + Waiting []RosterPeer `json:"waiting,omitempty"` |
| +} |
| + |
| +type RosterPeer struct { |
| + Peer PeerID `json:"peer"` |
| + Name string `json:"name"` |
| + Kind string `json:"kind"` |
| + Gen uint64 `json:"gen,omitempty"` |
| +} |
diff --git a/protocol/signal.go b/protocol/signal.go| new file mode 100644 |
| index 0000000..7d837d1 |
| --- /dev/null |
| +++ b/protocol/signal.go |
| @@ -0,0 +1,14 @@ |
| +package protocol |
| + |
| +import "encoding/json" |
| + |
| +// Signal is a client command. The server derives the sender from Peer’s |
| +// session binding, never from an asserted identity in the payload. |
| +type Signal struct { |
| + Peer PeerID `json:"peer"` |
| + Type string `json:"type"` |
| + To PeerID `json:"to,omitempty"` |
| + Seq uint64 `json:"seq,omitempty"` |
| + Gen uint64 `json:"gen,omitempty"` |
| + Payload json.RawMessage `json:"payload,omitempty"` |
| +} |
diff --git a/test/browser/native-auth.mjs b/test/browser/native-auth.mjs| new file mode 100644 |
| index 0000000..1d55180 |
| --- /dev/null |
| +++ b/test/browser/native-auth.mjs |
| @@ -0,0 +1,35 @@ |
| +import { chromium } from 'playwright'; |
| +import { createInterface } from 'node:readline'; |
| +const lines = createInterface({ input: process.stdin }); |
| +const input = lines[Symbol.asyncIterator](); |
| +const config = JSON.parse((await input.next()).value); |
| +const browser = await chromium.launch({headless:true}); |
| +try { |
| + // A fresh default-browser profile; no cookie is copied out to the app. |
| + // JavaScript is disabled to verify the entire sign-in/consent flow is HTML. |
| + const context = await browser.newContext({javaScriptEnabled:false}); |
| + const page = await context.newPage(); |
| + await page.goto(config.url); |
| + process.stdout.write("stage:signin\n"); |
| + await page.locator('input[name="address"]').fill(config.address); |
| + await Promise.all([ |
| + page.waitForURL('**/signin?sent=1'), |
| + page.locator('form[action="/signin"] button[type="submit"]').click(), |
| + ]); |
| + process.stdout.write('mail\n'); |
| + const link = JSON.parse((await input.next()).value).link; |
| + // Email opens a NEW browser tab, exactly as the reported failure did. |
| + const email = await context.newPage(); |
| + await email.goto(link); |
| + process.stdout.write("stage:email-opened\n"); |
| + await email.waitForURL('**/native/authorize?*'); |
| + if (!(await email.locator("body").innerText()).includes(config.code)) throw Error("confirmation code differs from app"); |
| + await email.getByRole('button', {name:'Sign in to Meet app', exact:true}).click(); |
| + await email.waitForURL('**/native/complete'); |
| + if (!(await email.locator('body').innerText()).includes('Return to the Meet app')) throw Error('missing completion'); |
| + process.stdout.write('approved\n'); |
| +} finally { |
| + await browser.close(); |
| + lines.close(); |
| + process.stdin.destroy(); |
| +} |
diff --git a/test/browser/native.mjs b/test/browser/native.mjs| new file mode 100644 |
| index 0000000..b0e6f49 |
| --- /dev/null |
| +++ b/test/browser/native.mjs |
| @@ -0,0 +1,96 @@ |
| +import { chromium } from 'playwright'; |
| +import { spawn } from 'node:child_process'; |
| +import { createInterface } from 'node:readline'; |
| +import assert from 'node:assert/strict'; |
| + |
| +const [origin, room, binary, mode] = process.argv.slice(2); |
| +const browser = await chromium.launch({ headless: true, args: [ |
| + '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', |
| + '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', |
| +] }); |
| +const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| +await context.addCookies([{ name: process.env.MEET_COOKIE_NAME, value: process.env.MEET_COOKIE_A, url: origin }]); |
| +const page = await context.newPage(); |
| +await page.addInitScript(() => { |
| + const Native = window.RTCPeerConnection; |
| + window.__nativeInteropPCs = new Set(); |
| + window.RTCPeerConnection = class extends Native { |
| + constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); } |
| + }; |
| +}); |
| +let native; |
| +try { |
| + await page.goto(`${origin}/m/${room}`); |
| + native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| + native.stdin.write(process.env.MEET_NATIVE_INPUT + '\n'); |
| + let sample = null; |
| + let exited = false; |
| + native.on('exit', () => { exited = true; }); |
| + // Native logs must never print credentials, SDP or candidates. Keep only |
| + // the gate's explicit numeric observations and suppress library diagnostics. |
| + createInterface({ input: native.stdout }).on('line', line => { |
| + try { const value = JSON.parse(line); if (value.stats) sample = value; } catch {} |
| + }); |
| + native.stderr.resume(); |
| + const wait = async (predicate, label, seconds = 35) => { |
| + const until = Date.now() + seconds * 1000; |
| + while (Date.now() < until) { |
| + if (exited) throw new Error('native executable exited'); |
| + if (await predicate()) return; |
| + await new Promise(r => setTimeout(r, 250)); |
| + } |
| + throw new Error(label + ': ' + JSON.stringify(sample)); |
| + }; |
| + if (mode === 'guest') { |
| + await page.getByRole('button', { name: 'Admit', exact: true }).waitFor(); |
| + await wait(() => sample !== null, "native waiting observation"); |
| + await new Promise(r => setTimeout(r, 1500)); |
| + assert.equal(sample?.stats.framesDecoded ?? 0, 0, 'waiting guest receives no video'); |
| + assert.equal(sample?.stats.audioBytesReceived ?? 0, 0, 'waiting guest receives no audio'); |
| + await page.getByRole('button', { name: 'Admit', exact: true }).click(); |
| + console.log('PASS guest media waits for member admission'); |
| + } |
| + await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.audioBytesReceived > 0 && sample?.stats.videoBytesSent > 0 && sample?.stats.audioBytesSent > 0, 'native receives browser audio/video'); |
| + console.log('PASS native decoded browser video and received audio'); |
| + // Each remote video has its own decoded frame count; local preview alone |
| + // cannot satisfy this check. Read the actual room test observation contract. |
| + // Remote videos exclude the muted local preview. getVideoPlaybackQuality |
| + // measures decoded frames in the actual browser renderer. |
| + const remoteFrames = () => page.evaluate(() => [...document.querySelectorAll('#tiles video')] |
| + .filter(v => !v.muted).reduce((n,v) => n + (v.getVideoPlaybackQuality?.().totalVideoFrames ?? 0), 0)); |
| + await wait(async () => await remoteFrames() > 15, 'browser decodes native video'); |
| + const browserAudio = () => page.evaluate(async () => { |
| + let bytes = 0; |
| + for (const pc of window.__nativeInteropPCs) { |
| + if (pc.connectionState === 'closed') continue; |
| + const stats = await pc.getStats(); |
| + for (const report of stats.values()) { |
| + if (report.type === 'inbound-rtp' && report.kind === 'audio') bytes += report.bytesReceived || 0; |
| + } |
| + } |
| + return bytes; |
| + }); |
| + await wait(async () => await browserAudio() > 0, 'browser receives native audio'); |
| + console.log('PASS browser decoded native video and received native audio'); |
| + const peer = sample.peer; |
| + const beforeNative = sample.stats.framesDecoded; |
| + const beforeBrowser = await remoteFrames(); |
| + const beforeAudio = await browserAudio(); |
| + const beforeNativeAudio = sample.stats.audioBytesReceived; |
| + // The test server renews streams every three seconds. Observe fresh frames |
| + // well after two renewals, and require the same native participation. |
| + await new Promise(r => setTimeout(r, 8000)); |
| + await wait(async () => sample.stats.framesDecoded > beforeNative + 20 && await remoteFrames() > beforeBrowser + 20 && await browserAudio() > beforeAudio && sample.stats.audioBytesReceived > beforeNativeAudio, 'media survives stream renewal'); |
| + assert.equal(sample.peer, peer, 'resume must preserve the native participation'); |
| + console.log('PASS native/browser media continues across SSE renewals'); |
| + const left = new Promise((resolve, reject) => { |
| + native.once('exit', code => code === 0 ? resolve() : reject(new Error('native leave failed'))); |
| + }); |
| + native.stdin.write('leave\n'); |
| + await Promise.race([left, new Promise((_,reject) => { setTimeout(() => reject(new Error('native leave timed out')), 5000).unref(); })]); |
| + await page.waitForFunction(() => [...document.querySelectorAll('#tiles video')].filter(v => !v.muted).length === 0); |
| + console.log('PASS leaving native call removes its browser tile'); |
| +} finally { |
| + native?.kill('SIGTERM'); |
| + await browser.close(); |
| +} |