| 1 | // Package bridge is the deliberately small gomobile surface. It imports only |
| 2 | // Calls' client core, never the database, server, or at-rest encryption keys. |
| 3 | package bridge |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/base64" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "net/http" |
| 11 | "net/http/cookiejar" |
| 12 | "net/url" |
| 13 | |
| 14 | "amadan.net/oficina/calls/callscore" |
| 15 | "amadan.net/oficina/calls/protocol" |
| 16 | ) |
| 17 | |
| 18 | // Listener receives JSON events on a Go callback thread. Enqueue on the main |
| 19 | // actor before touching Apple UI or WebRTC. Never block or call Close inline. |
| 20 | type Listener interface{ OnEvent(event string) } |
| 21 | |
| 22 | type Client struct{ room *callscore.Room } |
| 23 | |
| 24 | // sessionJar turns the cookies the sign-in handoff returned into the jar |
| 25 | // every authenticated call in this package is made on. It is shared by |
| 26 | // every function here that takes cookies, so the rules about what counts |
| 27 | // as a session — one cookie, the framework's name, thirty-two decoded |
| 28 | // bytes — are written once. Cookies are scoped to the exact supplied |
| 29 | // origin, and the jar lives only as long as the call that made it: |
| 30 | // keeping the session between launches is the host's business, and |
| 31 | // CallsKit keeps it in the Keychain. |
| 32 | func sessionJar(origin, cookiesJSON string) (*cookiejar.Jar, error) { |
| 33 | u, err := url.Parse(origin) |
| 34 | if err != nil { |
| 35 | return nil, errors.New("invalid Calls origin") |
| 36 | } |
| 37 | var cookies []struct { |
| 38 | Name string `json:"name"` |
| 39 | Value string `json:"value"` |
| 40 | } |
| 41 | if len(cookiesJSON) > 8192 || json.Unmarshal([]byte(cookiesJSON), &cookies) != nil { |
| 42 | return nil, errors.New("invalid session cookies") |
| 43 | } |
| 44 | jar, _ := cookiejar.New(nil) |
| 45 | session := false |
| 46 | for _, c := range cookies { |
| 47 | if c.Name != "__Host-rastrillo_session" && c.Name != "rastrillo_session" { |
| 48 | continue |
| 49 | } |
| 50 | if session { |
| 51 | return nil, errors.New("ambiguous Calls session") |
| 52 | } |
| 53 | raw, err := base64.RawURLEncoding.DecodeString(c.Value) |
| 54 | if err != nil || len(raw) != 32 || base64.RawURLEncoding.EncodeToString(raw) != c.Value { |
| 55 | return nil, errors.New("invalid Calls session") |
| 56 | } |
| 57 | session = true |
| 58 | jar.SetCookies(u, []*http.Cookie{{Name: c.Name, Value: c.Value, Path: "/", Secure: u.Scheme == "https", HttpOnly: true}}) |
| 59 | } |
| 60 | if !session { |
| 61 | return nil, errors.New("sign in to Calls first") |
| 62 | } |
| 63 | return jar, nil |
| 64 | } |
| 65 | |
| 66 | // StartMeeting creates a meeting on an instance the app is signed in to |
| 67 | // and answers its identifier, for the app that has no link because the |
| 68 | // meeting does not exist yet. The caller joins it exactly as it joins a |
| 69 | // meeting someone sent: NewClient with the same origin and cookies. |
| 70 | // |
| 71 | // zone is the device's IANA time zone, for the title the server writes. |
| 72 | func StartMeeting(origin, cookiesJSON, zone string) (string, error) { |
| 73 | jar, err := sessionJar(origin, cookiesJSON) |
| 74 | if err != nil { |
| 75 | return "", err |
| 76 | } |
| 77 | return callscore.StartMeetingIn(context.Background(), origin, zone, &http.Client{Jar: jar}) |
| 78 | } |
| 79 | |
| 80 | // NewClient uses the ordinary session returned by the browser sign-in handoff. |
| 81 | // This call does not mint, exchange, or accept an agent bearer token. |
| 82 | func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, error) { |
| 83 | if listener == nil { |
| 84 | return nil, errors.New("a listener is required") |
| 85 | } |
| 86 | jar, err := sessionJar(origin, cookiesJSON) |
| 87 | if err != nil { |
| 88 | return nil, err |
| 89 | } |
| 90 | r, err := callscore.New(origin, room, &http.Client{Jar: jar}, func(event callscore.Event) { |
| 91 | b, err := json.Marshal(event) |
| 92 | if err == nil { |
| 93 | listener.OnEvent(string(b)) |
| 94 | } |
| 95 | }) |
| 96 | if err != nil { |
| 97 | return nil, err |
| 98 | } |
| 99 | return &Client{room: r}, nil |
| 100 | } |
| 101 | func (c *Client) Start() error { return c.room.Start() } |
| 102 | func (c *Client) ShareLink() (string, error) { return c.room.ShareLink() } |
| 103 | func (c *Client) Send(typ, to string, generation int64, payload string) error { |
| 104 | if generation < 0 { |
| 105 | return errors.New("invalid generation") |
| 106 | } |
| 107 | return c.room.Send(typ, to, uint64(generation), payload) |
| 108 | } |
| 109 | func (c *Client) Leave() { c.room.Leave() } |
| 110 | func (c *Client) Close() { c.room.Close() } |
| 111 | |
| 112 | // SetMedia is this app's own microphone and camera state (spec §15.19). |
| 113 | // The core reports it once admitted, again whenever a roster says the |
| 114 | // server has not got it, and at once on every change. |
| 115 | func (c *Client) SetMedia(muted, cameraOff bool) error { return c.room.SetMedia(muted, cameraOff) } |
| 116 | |
| 117 | // SetNoSoundFrom is spec §15.20's receiving half: the peers whose audio |
| 118 | // is reaching this app as silence, as a JSON array of peer ids (gomobile |
| 119 | // carries no slices). The core keeps only peers in its roster and |
| 120 | // reports the list with the rest of this app's media state; "[]" or "" |
| 121 | // clears it. |
| 122 | func (c *Client) SetNoSoundFrom(peersJSON string) error { |
| 123 | var ids []protocol.PeerID |
| 124 | if peersJSON != "" { |
| 125 | if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil { |
| 126 | return errors.New("invalid peer list") |
| 127 | } |
| 128 | } |
| 129 | return c.room.SetNoSoundFrom(ids) |
| 130 | } |
| 131 | |
| 132 | // SetScreenStalled is spec §15.21's receiving half: the peers whose shared |
| 133 | // screen has stopped moving here, as a JSON array of peer ids. Reported |
| 134 | // with the rest of this app's media state; "[]" or "" clears it. |
| 135 | func (c *Client) SetScreenStalled(peersJSON string) error { |
| 136 | var ids []protocol.PeerID |
| 137 | if peersJSON != "" { |
| 138 | if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil { |
| 139 | return errors.New("invalid peer list") |
| 140 | } |
| 141 | } |
| 142 | return c.room.SetScreenStalled(ids) |
| 143 | } |
| 144 | |
| 145 | // SetHearing is spec §15.24: the peers whose audio this app is actually |
| 146 | // receiving and playing, as a JSON array of peer ids. Reported with the |
| 147 | // rest of this app's media state; "[]" or "" is "nobody yet", which is |
| 148 | // still said — the key's presence is what tells the others this app |
| 149 | // reports it. |
| 150 | func (c *Client) SetHearing(peersJSON string) error { |
| 151 | ids := []protocol.PeerID{} |
| 152 | if peersJSON != "" { |
| 153 | if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil { |
| 154 | return errors.New("invalid peer list") |
| 155 | } |
| 156 | } |
| 157 | return c.room.SetHearing(ids) |
| 158 | } |
| 159 | |
| 160 | // SendQuality posts one quality report (spec §15.26), a |
| 161 | // protocol.QualityReport as JSON, with the hello's report token. It |
| 162 | // answers what became of it, as a word — gomobile hands Swift every error |
| 163 | // as the same NSError, so an outcome the host acts on is a value: |
| 164 | // |
| 165 | // - "sent": taken; |
| 166 | // - "stop": the server's kill switch; send no more this call; |
| 167 | // - "too-many", "gone", "failed": skipped; the next window tries again. |
| 168 | // |
| 169 | // It works after Leave and Close (the final report goes at leave), and |
| 170 | // blocks for up to ten seconds: call it off the main thread. |
| 171 | func (c *Client) SendQuality(report string) string { |
| 172 | stop, err := c.room.SendQuality([]byte(report)) |
| 173 | if err == nil && stop { |
| 174 | return string(callscore.ReportStop) |
| 175 | } |
| 176 | return string(callscore.OutcomeOf(err)) |
| 177 | } |
| 178 | |
| 179 | // SendProblem posts "Report a problem" (spec §15.26), a |
| 180 | // protocol.ProblemReport as JSON, and answers JSON: {"ref":"K7Q2M9PD"} |
| 181 | // when the server took it, or {"outcome":"too-many"|"gone"|"failed"}. |
| 182 | // Like SendQuality it works for as long as the server keeps the token — |
| 183 | // ten minutes past the call — and blocks; call it off the main thread. |
| 184 | func (c *Client) SendProblem(report string) string { |
| 185 | ref, err := c.room.SendProblem([]byte(report)) |
| 186 | var out struct { |
| 187 | Ref string `json:"ref,omitempty"` |
| 188 | Outcome string `json:"outcome,omitempty"` |
| 189 | } |
| 190 | if err == nil { |
| 191 | out.Ref = ref |
| 192 | } else { |
| 193 | out.Outcome = string(callscore.OutcomeOf(err)) |
| 194 | } |
| 195 | b, _ := json.Marshal(out) |
| 196 | return string(b) |
| 197 | } |
| 198 | |
| 199 | // RegisterDevice hands the server an Apple device token so a knock can |
| 200 | // reach this device when the app is closed. Best effort: the caller |
| 201 | // ignores the error beyond logging, because a device that did not |
| 202 | // register is simply one that will not be pushed to. |
| 203 | func RegisterDevice(origin, cookiesJSON, token, platform string) error { |
| 204 | jar, err := sessionJar(origin, cookiesJSON) |
| 205 | if err != nil { |
| 206 | return err |
| 207 | } |
| 208 | return callscore.RegisterDevice(context.Background(), origin, &http.Client{Jar: jar}, token, platform) |
| 209 | } |
| 210 | |
| 211 | // SignedOut is CheckSession's third answer, beside callscore's Member |
| 212 | // and Participant: the instance says this session no longer signs |
| 213 | // anyone in. It is a value rather than an error because the host acts |
| 214 | // on it — forgets the copy it kept — and gomobile hands Swift every |
| 215 | // error as the same NSError, told apart only by its words. |
| 216 | const SignedOut = "signed-out" |
| 217 | |
| 218 | // CheckSession answers "member", "participant" or "signed-out" for a |
| 219 | // session the host held on to, and an error only when the instance |
| 220 | // could not be asked or its answer could not be read. The host keeps |
| 221 | // its credential through an error and forgets it on "signed-out". |
| 222 | // |
| 223 | // Cookies this package would refuse to send are "signed-out" too: a |
| 224 | // stored copy that no longer parses is no more use than an expired one. |
| 225 | func CheckSession(origin, cookiesJSON string) (string, error) { |
| 226 | jar, err := sessionJar(origin, cookiesJSON) |
| 227 | if err != nil { |
| 228 | return SignedOut, nil |
| 229 | } |
| 230 | standing, err := callscore.CheckSession(context.Background(), origin, &http.Client{Jar: jar}) |
| 231 | if errors.Is(err, callscore.ErrSignedOut) { |
| 232 | return SignedOut, nil |
| 233 | } |
| 234 | if err != nil { |
| 235 | return "", err |
| 236 | } |
| 237 | return string(standing), nil |
| 238 | } |
| 239 | |
| 240 | // OpenLink resolves a meeting or invitation address on the instance the |
| 241 | // host is signed in to, without the browser, and answers JSON: |
| 242 | // {"room": "..."} — empty for the instance's own address —, |
| 243 | // {"signedOut": true} when the session has gone and the address needs a |
| 244 | // fresh sign-in, or {"noAccess": true} when the session is fine but is |
| 245 | // not someone who may enter that meeting. JSON rather than a bare room |
| 246 | // so that "no meeting" and "no session" cannot be mistaken for each other. |
| 247 | func OpenLink(origin, cookiesJSON, address string) (string, error) { |
| 248 | type answer struct { |
| 249 | Room string `json:"room"` |
| 250 | SignedOut bool `json:"signedOut,omitempty"` |
| 251 | NoAccess bool `json:"noAccess,omitempty"` |
| 252 | } |
| 253 | var out answer |
| 254 | jar, err := sessionJar(origin, cookiesJSON) |
| 255 | if err == nil { |
| 256 | out.Room, err = callscore.OpenLink(context.Background(), origin, address, &http.Client{Jar: jar}) |
| 257 | } else { |
| 258 | err = callscore.ErrSignedOut |
| 259 | } |
| 260 | if errors.Is(err, callscore.ErrSignedOut) { |
| 261 | out = answer{SignedOut: true} |
| 262 | } else if errors.Is(err, callscore.ErrNoAccess) { |
| 263 | out = answer{NoAccess: true} |
| 264 | } else if err != nil { |
| 265 | return "", err |
| 266 | } |
| 267 | b, err := json.Marshal(out) |
| 268 | return string(b), err |
| 269 | } |
| 270 | |
| 271 | // SignOut asks the instance to revoke the session. The host forgets its |
| 272 | // copy whatever this answers; see callscore.SignOut. |
| 273 | func SignOut(origin, cookiesJSON string) error { |
| 274 | jar, err := sessionJar(origin, cookiesJSON) |
| 275 | if err != nil { |
| 276 | return err |
| 277 | } |
| 278 | return callscore.SignOut(context.Background(), origin, &http.Client{Jar: jar}) |
| 279 | } |
| 280 | |