oficina / meet Public

Merge branch 'codex/native-mac-milestone' into main

Add native Mac Meet with browser sign-in handoff
paul@keymail.dev 86ed9f39f6d7263b029ec885053887c1fae18a8f parent a283c53parent 7edf51e
61 files changed, +4541 −121
  • .gitignore +5 −0
  • AGENTS.md +4 −1
  • Makefile +15 −0
  • cmd/meet/main.go +2 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +33 −1
  • go.mod +5 −1
  • go.sum +8 −2
  • internal/meet/app.go +45 −7
  • internal/meet/auth.go +12 −3
  • internal/meet/config.go +7 −0
  • internal/meet/handlers_join.go +7 −1
  • internal/meet/handlers_meetings.go +1 −1
  • internal/meet/handlers_room.go +10 −0
  • internal/meet/home.go +123 −0
  • internal/meet/home_native_test.go +121 −0
  • internal/meet/home_release_test.go +143 −0
  • internal/meet/home_router_test.go +331 −0
  • internal/meet/home_security_test.go +92 −0
  • internal/meet/hub/messages.go +37 −95
  • internal/meet/native_auth.go +241 −0
  • internal/meet/native_auth_test.go +140 −0
  • internal/meet/render.go +5 −1
  • internal/meet/signal.go +2 −8
  • internal/meet/store.go +14 −0
  • internal/meet/templates/home.html +2 −0
  • internal/meet/templates/index.html +1 −0
  • internal/meet/templates/native.html +13 −0
  • internal/meettest/native_auth_browser_test.go +109 −0
  • internal/meettest/native_browser_test.go +79 −0
  • internal/meettest/room_test.go +27 −0
  • meetcore/auth.go +166 −0
  • meetcore/auth_test.go +77 −0
  • meetcore/room.go +540 −0
  • meetcore/room_test.go +195 −0
  • meetcore/transport.go +213 −0
  • meetcore/transport_test.go +62 −0
  • native/README.md +81 −0
  • native/VERIFICATION.md +58 −0
  • native/apple/Package.resolved +14 −0
  • native/apple/Package.swift +21 −0
  • native/apple/Sources/MeetInterop/Interop.swift +44 −0
  • native/apple/Sources/MeetInterop/SyntheticVideo.swift +46 −0
  • native/apple/Sources/MeetKit/Call.swift +177 −0
  • native/apple/Sources/MeetKit/Capture.swift +60 −0
  • native/apple/Sources/MeetKit/Media.swift +312 −0
  • native/apple/Sources/MeetKit/Wire.swift +70 −0
  • native/apple/Sources/MeetMac/MeetMac.swift +261 −0
  • native/architecture.md +26 −0
  • native/bridge/auth.go +29 −0
  • native/bridge/bridge.go +78 −0
  • native/bridge/bridge_test.go +31 −0
  • native/go.mod +19 −0
  • native/go.sum +14 −0
  • native/scripts/build-bridge.sh +7 −0
  • native/scripts/build-mac.sh +8 −0
  • native/scripts/bundle-mac.py +36 −0
  • protocol/auth.go +15 −0
  • protocol/messages.go +102 −0
  • protocol/signal.go +14 −0
  • test/browser/native-auth.mjs +35 −0
  • test/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();
+}