oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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.
3package bridge
4
5import (
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.
20type Listener interface{ OnEvent(event string) }
21
22type 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.
32func 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.
72func 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.
82func 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}
101func (c *Client) Start() error { return c.room.Start() }
102func (c *Client) ShareLink() (string, error) { return c.room.ShareLink() }
103func (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}
109func (c *Client) Leave() { c.room.Leave() }
110func (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.
115func (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.
122func (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.
135func (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.
150func (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.
171func (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.
184func (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.
203func 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.
216const 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.
225func 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.
247func 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.
273func 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