1/* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17'use strict'; 18 19// The public elements in this file implement the Server Connector Interface, 20// part of the contract between the signaling server and the webrtc client. 21// No changes that break backward compatibility are allowed here. Any new 22// features must be added as a new function/class in the interface. Any 23// additions to the interface must be checked for existence by the client before 24// using it. 25 26// The id of the device the client is supposed to connect to. 27// The List Devices page in the signaling server may choose any way to pass the 28// device id to the client page, this function retrieves that information once 29// the client loaded. 30// In this case the device id is passed as a parameter in the url. 31export function deviceId() { 32 const urlParams = new URLSearchParams(window.location.search); 33 return urlParams.get('deviceId'); 34} 35 36// Creates a connector capable of communicating with the signaling server. 37export async function createConnector() { 38 try { 39 let ws = await connectWs(); 40 console.debug(`Connected to ${ws.url}`); 41 return new WebsocketConnector(ws); 42 } catch (e) { 43 console.error('WebSocket error:', e); 44 } 45 console.warn('Failed to connect websocket, trying polling instead'); 46 47 return new PollingConnector(); 48} 49 50// A connector object provides high level functions for communicating with the 51// signaling server, while hiding away implementation details. 52// This class is an interface and shouldn't be instantiated direclty. 53// Only the public methods present in this class form part of the Server 54// Connector Interface, any implementations of the interface are considered 55// internal and not accessible to client code. 56class Connector { 57 constructor() { 58 if (this.constructor == Connector) { 59 throw new Error('Connector is an abstract class'); 60 } 61 } 62 63 // Registers a callback to receive messages from the device. A race may occur 64 // if this is called after requestDevice() is called in which some device 65 // messages are lost. 66 onDeviceMsg(cb) { 67 throw 'Not implemented!'; 68 } 69 70 // Selects a particular device in the signaling server and opens the signaling 71 // channel with it (but doesn't send any message to the device). Returns a 72 // promise to an object with the following properties: 73 // - deviceInfo: The info object provided by the device when it registered 74 // with the server. 75 // - infraConfig: The server's infrastructure configuration (mainly STUN and 76 // TURN servers) 77 // The promise may take a long time to resolve if, for example, the server 78 // decides to wait for a device with the provided id to register with it. The 79 // promise may be rejected if there are connectivity issues, a device with 80 // that id doesn't exist or this client doesn't have rights to access that 81 // device. 82 async requestDevice(deviceId) { 83 throw 'Not implemented!'; 84 } 85 86 // Sends a message to the device selected with requestDevice. It's an error to 87 // call this function before the promise from requestDevice() has resolved. 88 // Returns an empty promise that is rejected when the message can not be 89 // delivered, either because the device has not been requested yet or because 90 // of connectivity issues. 91 async sendToDevice(msg) { 92 throw 'Not implemented!'; 93 } 94} 95 96// Returns real implementation for ParentController. 97export function createParentController() { 98 return null; 99} 100 101// ParentController object provides methods for sending information from device 102// UI to operator UI. This class is just an interface and real implementation is 103// at the operator side. This class shouldn't be instantiated directly. 104class ParentController { 105 constructor() { 106 if (this.constructor === ParentController) { 107 throw new Error('ParentController is an abstract class'); 108 } 109 } 110 111 // Create and return a message object that contains display information of 112 // device. Created object can be sent to operator UI using send() method. 113 // rotation argument is device's physycan rotation so it will be commonly 114 // applied to all displays. 115 createDeviceDisplaysMessage(rotation) { 116 throw 'Not implemented'; 117 } 118} 119 120// This class represents displays information for a device. This message is 121// intended to be sent to operator UI to determine panel size of device UI. 122// This is an abstract class and should not be instantiated directly. This 123// message is created using createDeviceDisplaysMessage method of 124// ParentController. Real implementation of this class is at operator side. 125export class DeviceDisplaysMessage { 126 constructor(parentController, rotation) { 127 if (this.constructor === DeviceDisplaysMessage) { 128 throw new Error('DeviceDisplaysMessage is an abstract class'); 129 } 130 } 131 132 // Add a display information to deviceDisplays message. 133 addDisplay(display_id, width, height) { 134 throw 'Not implemented' 135 } 136 137 // Send DeviceDisplaysMessage created using createDeviceDisplaysMessage to 138 // operator UI. If operator UI does not exist (in the case device web page 139 // is opened directly), the message will just be ignored. 140 send() { 141 throw 'Not implemented' 142 } 143} 144 145// End of Server Connector Interface. 146 147// The following code is internal and shouldn't be accessed outside this file. 148 149function httpUrl(path) { 150 return location.protocol + '//' + location.host + '/' + path; 151} 152 153function websocketUrl(path) { 154 return ((location.protocol == 'http:') ? 'ws://' : 'wss://') + location.host + 155 '/' + path; 156} 157 158const kPollConfigUrl = httpUrl('infra_config'); 159const kPollConnectUrl = httpUrl('connect'); 160const kPollForwardUrl = httpUrl('forward'); 161const kPollMessagesUrl = httpUrl('poll_messages'); 162 163async function connectWs() { 164 return new Promise((resolve, reject) => { 165 let url = websocketUrl('connect_client'); 166 let ws = new WebSocket(url); 167 ws.onopen = () => { 168 resolve(ws); 169 }; 170 ws.onerror = evt => { 171 reject(evt); 172 }; 173 }); 174} 175 176async function ajaxPostJson(url, data) { 177 const response = await fetch(url, { 178 method: 'POST', 179 cache: 'no-cache', 180 headers: {'Content-Type': 'application/json'}, 181 redirect: 'follow', 182 body: JSON.stringify(data), 183 }); 184 return response.json(); 185} 186 187// Implementation of the connector interface using websockets 188class WebsocketConnector extends Connector { 189 #websocket; 190 #futures = {}; 191 #onDeviceMsgCb = msg => 192 console.error('Received device message without registered listener'); 193 194 onDeviceMsg(cb) { 195 this.#onDeviceMsgCb = cb; 196 } 197 198 constructor(ws) { 199 super(); 200 ws.onmessage = e => { 201 let data = JSON.parse(e.data); 202 this.#onWebsocketMessage(data); 203 }; 204 this.#websocket = ws; 205 } 206 207 async requestDevice(deviceId) { 208 return new Promise((resolve, reject) => { 209 this.#futures.onDeviceAvailable = (device) => resolve(device); 210 this.#futures.onConnectionFailed = (error) => reject(error); 211 this.#wsSendJson({ 212 message_type: 'connect', 213 device_id: deviceId, 214 }); 215 }); 216 } 217 218 async sendToDevice(msg) { 219 return this.#wsSendJson({message_type: 'forward', payload: msg}); 220 } 221 222 #onWebsocketMessage(message) { 223 const type = message.message_type; 224 if (message.error) { 225 console.error(message.error); 226 this.#futures.onConnectionFailed(message.error); 227 return; 228 } 229 switch (type) { 230 case 'config': 231 this.#futures.infraConfig = message; 232 break; 233 case 'device_info': 234 if (this.#futures.onDeviceAvailable) { 235 this.#futures.onDeviceAvailable({ 236 deviceInfo: message.device_info, 237 infraConfig: this.#futures.infraConfig, 238 }); 239 delete this.#futures.onDeviceAvailable; 240 } else { 241 console.error('Received unsolicited device info'); 242 } 243 break; 244 case 'device_msg': 245 this.#onDeviceMsgCb(message.payload); 246 break; 247 default: 248 console.error('Unrecognized message type from server: ', type); 249 this.#futures.onConnectionFailed( 250 'Unrecognized message type from server: ' + type); 251 console.error(message); 252 } 253 } 254 255 async #wsSendJson(obj) { 256 return this.#websocket.send(JSON.stringify(obj)); 257 } 258} 259 260// Implementation of the Connector interface using HTTP long polling 261class PollingConnector extends Connector { 262 #connId = undefined; 263 #config = undefined; 264 #pollerSchedule; 265 #onDeviceMsgCb = msg => 266 console.error('Received device message without registered listener'); 267 268 onDeviceMsg(cb) { 269 this.#onDeviceMsgCb = cb; 270 } 271 272 constructor() { 273 super(); 274 } 275 276 async requestDevice(deviceId) { 277 let config = await this.#getConfig(); 278 let response = await ajaxPostJson(kPollConnectUrl, {device_id: deviceId}); 279 this.#connId = response.connection_id; 280 281 this.#startPolling(); 282 283 return { 284 deviceInfo: response.device_info, 285 infraConfig: config, 286 }; 287 } 288 289 async sendToDevice(msg) { 290 // Forward messages act like polling messages as well 291 let device_messages = await this.#forward(msg); 292 for (const message of device_messages) { 293 this.#onDeviceMsgCb(message); 294 } 295 } 296 297 async #getConfig() { 298 if (this.#config === undefined) { 299 this.#config = await (await fetch(kPollConfigUrl, { 300 method: 'GET', 301 redirect: 'follow', 302 })).json(); 303 } 304 return this.#config; 305 } 306 307 async #forward(msg) { 308 return await ajaxPostJson(kPollForwardUrl, { 309 connection_id: this.#connId, 310 payload: msg, 311 }); 312 } 313 314 async #pollMessages() { 315 return await ajaxPostJson(kPollMessagesUrl, { 316 connection_id: this.#connId, 317 }); 318 } 319 320 #startPolling() { 321 if (this.#pollerSchedule !== undefined) { 322 return; 323 } 324 325 let currentPollDelay = 1000; 326 let pollerRoutine = async () => { 327 let messages = await this.#pollMessages(); 328 329 // Do exponential backoff on the polling up to 60 seconds 330 currentPollDelay = Math.min(60000, 2 * currentPollDelay); 331 for (const message of messages) { 332 this.#onDeviceMsgCb(message); 333 // There is at least one message, poll sooner 334 currentPollDelay = 1000; 335 } 336 this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay); 337 }; 338 339 this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay); 340 } 341} 342