// Package bridge is the deliberately small gomobile surface. It imports only // Calls' client core, never the database, server, or at-rest encryption keys. package bridge import ( "context" "encoding/base64" "encoding/json" "errors" "net/http" "net/http/cookiejar" "net/url" "amadan.net/oficina/calls/callscore" "amadan.net/oficina/calls/protocol" ) // 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 *callscore.Room } // sessionJar turns the cookies the sign-in handoff returned into the jar // every authenticated call in this package is made on. It is shared by // every function here that takes cookies, so the rules about what counts // as a session — one cookie, the framework's name, thirty-two decoded // bytes — are written once. Cookies are scoped to the exact supplied // origin, and the jar lives only as long as the call that made it: // keeping the session between launches is the host's business, and // CallsKit keeps it in the Keychain. func sessionJar(origin, cookiesJSON string) (*cookiejar.Jar, error) { u, err := url.Parse(origin) if err != nil { return nil, errors.New("invalid Calls 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 Calls 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 Calls 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 Calls first") } return jar, nil } // StartMeeting creates a meeting on an instance the app is signed in to // and answers its identifier, for the app that has no link because the // meeting does not exist yet. The caller joins it exactly as it joins a // meeting someone sent: NewClient with the same origin and cookies. // // zone is the device's IANA time zone, for the title the server writes. func StartMeeting(origin, cookiesJSON, zone string) (string, error) { jar, err := sessionJar(origin, cookiesJSON) if err != nil { return "", err } return callscore.StartMeetingIn(context.Background(), origin, zone, &http.Client{Jar: jar}) } // NewClient uses the ordinary session returned by the browser sign-in handoff. // This call does not mint, exchange, or accept an agent bearer token. func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, error) { if listener == nil { return nil, errors.New("a listener is required") } jar, err := sessionJar(origin, cookiesJSON) if err != nil { return nil, err } r, err := callscore.New(origin, room, &http.Client{Jar: jar}, func(event callscore.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) ShareLink() (string, error) { return c.room.ShareLink() } 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() } // SetMedia is this app's own microphone and camera state (spec §15.19). // The core reports it once admitted, again whenever a roster says the // server has not got it, and at once on every change. func (c *Client) SetMedia(muted, cameraOff bool) error { return c.room.SetMedia(muted, cameraOff) } // SetNoSoundFrom is spec §15.20's receiving half: the peers whose audio // is reaching this app as silence, as a JSON array of peer ids (gomobile // carries no slices). The core keeps only peers in its roster and // reports the list with the rest of this app's media state; "[]" or "" // clears it. func (c *Client) SetNoSoundFrom(peersJSON string) error { var ids []protocol.PeerID if peersJSON != "" { if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil { return errors.New("invalid peer list") } } return c.room.SetNoSoundFrom(ids) } // SetScreenStalled is spec §15.21's receiving half: the peers whose shared // screen has stopped moving here, as a JSON array of peer ids. Reported // with the rest of this app's media state; "[]" or "" clears it. func (c *Client) SetScreenStalled(peersJSON string) error { var ids []protocol.PeerID if peersJSON != "" { if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil { return errors.New("invalid peer list") } } return c.room.SetScreenStalled(ids) } // SetHearing is spec §15.24: the peers whose audio this app is actually // receiving and playing, as a JSON array of peer ids. Reported with the // rest of this app's media state; "[]" or "" is "nobody yet", which is // still said — the key's presence is what tells the others this app // reports it. func (c *Client) SetHearing(peersJSON string) error { ids := []protocol.PeerID{} if peersJSON != "" { if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil { return errors.New("invalid peer list") } } return c.room.SetHearing(ids) } // SendQuality posts one quality report (spec §15.26), a // protocol.QualityReport as JSON, with the hello's report token. It // answers what became of it, as a word — gomobile hands Swift every error // as the same NSError, so an outcome the host acts on is a value: // // - "sent": taken; // - "stop": the server's kill switch; send no more this call; // - "too-many", "gone", "failed": skipped; the next window tries again. // // It works after Leave and Close (the final report goes at leave), and // blocks for up to ten seconds: call it off the main thread. func (c *Client) SendQuality(report string) string { stop, err := c.room.SendQuality([]byte(report)) if err == nil && stop { return string(callscore.ReportStop) } return string(callscore.OutcomeOf(err)) } // SendProblem posts "Report a problem" (spec §15.26), a // protocol.ProblemReport as JSON, and answers JSON: {"ref":"K7Q2M9PD"} // when the server took it, or {"outcome":"too-many"|"gone"|"failed"}. // Like SendQuality it works for as long as the server keeps the token — // ten minutes past the call — and blocks; call it off the main thread. func (c *Client) SendProblem(report string) string { ref, err := c.room.SendProblem([]byte(report)) var out struct { Ref string `json:"ref,omitempty"` Outcome string `json:"outcome,omitempty"` } if err == nil { out.Ref = ref } else { out.Outcome = string(callscore.OutcomeOf(err)) } b, _ := json.Marshal(out) return string(b) } // RegisterDevice hands the server an Apple device token so a knock can // reach this device when the app is closed. Best effort: the caller // ignores the error beyond logging, because a device that did not // register is simply one that will not be pushed to. func RegisterDevice(origin, cookiesJSON, token, platform string) error { jar, err := sessionJar(origin, cookiesJSON) if err != nil { return err } return callscore.RegisterDevice(context.Background(), origin, &http.Client{Jar: jar}, token, platform) } // SignedOut is CheckSession's third answer, beside callscore's Member // and Participant: the instance says this session no longer signs // anyone in. It is a value rather than an error because the host acts // on it — forgets the copy it kept — and gomobile hands Swift every // error as the same NSError, told apart only by its words. const SignedOut = "signed-out" // CheckSession answers "member", "participant" or "signed-out" for a // session the host held on to, and an error only when the instance // could not be asked or its answer could not be read. The host keeps // its credential through an error and forgets it on "signed-out". // // Cookies this package would refuse to send are "signed-out" too: a // stored copy that no longer parses is no more use than an expired one. func CheckSession(origin, cookiesJSON string) (string, error) { jar, err := sessionJar(origin, cookiesJSON) if err != nil { return SignedOut, nil } standing, err := callscore.CheckSession(context.Background(), origin, &http.Client{Jar: jar}) if errors.Is(err, callscore.ErrSignedOut) { return SignedOut, nil } if err != nil { return "", err } return string(standing), nil } // OpenLink resolves a meeting or invitation address on the instance the // host is signed in to, without the browser, and answers JSON: // {"room": "..."} — empty for the instance's own address —, // {"signedOut": true} when the session has gone and the address needs a // fresh sign-in, or {"noAccess": true} when the session is fine but is // not someone who may enter that meeting. JSON rather than a bare room // so that "no meeting" and "no session" cannot be mistaken for each other. func OpenLink(origin, cookiesJSON, address string) (string, error) { type answer struct { Room string `json:"room"` SignedOut bool `json:"signedOut,omitempty"` NoAccess bool `json:"noAccess,omitempty"` } var out answer jar, err := sessionJar(origin, cookiesJSON) if err == nil { out.Room, err = callscore.OpenLink(context.Background(), origin, address, &http.Client{Jar: jar}) } else { err = callscore.ErrSignedOut } if errors.Is(err, callscore.ErrSignedOut) { out = answer{SignedOut: true} } else if errors.Is(err, callscore.ErrNoAccess) { out = answer{NoAccess: true} } else if err != nil { return "", err } b, err := json.Marshal(out) return string(b), err } // SignOut asks the instance to revoke the session. The host forgets its // copy whatever this answers; see callscore.SignOut. func SignOut(origin, cookiesJSON string) error { jar, err := sessionJar(origin, cookiesJSON) if err != nil { return err } return callscore.SignOut(context.Background(), origin, &http.Client{Jar: jar}) }