You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ScreenObtainer.js 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632
  1. /* global chrome, $, alert */
  2. import JitsiTrackError from '../../JitsiTrackError';
  3. import * as JitsiTrackErrors from '../../JitsiTrackErrors';
  4. import RTCBrowserType from './RTCBrowserType';
  5. const logger = require('jitsi-meet-logger').getLogger(__filename);
  6. const GlobalOnErrorHandler = require('../util/GlobalOnErrorHandler');
  7. /**
  8. * Indicates whether the Chrome desktop sharing extension is installed.
  9. * @type {boolean}
  10. */
  11. let chromeExtInstalled = false;
  12. /**
  13. * Indicates whether an update of the Chrome desktop sharing extension is
  14. * required.
  15. * @type {boolean}
  16. */
  17. let chromeExtUpdateRequired = false;
  18. /**
  19. * Whether the jidesha extension for firefox is installed for the domain on
  20. * which we are running. Null designates an unknown value.
  21. * @type {null}
  22. */
  23. let firefoxExtInstalled = null;
  24. /**
  25. * If set to true, detection of an installed firefox extension will be started
  26. * again the next time obtainScreenOnFirefox is called (e.g. next time the
  27. * user tries to enable screen sharing).
  28. */
  29. let reDetectFirefoxExtension = false;
  30. let gumFunction = null;
  31. /**
  32. * The error returned by chrome when trying to start inline installation from
  33. * popup.
  34. */
  35. const CHROME_EXTENSION_POPUP_ERROR
  36. = 'Inline installs can not be initiated from pop-up windows.';
  37. /**
  38. * The error returned by chrome when trying to start inline installation from
  39. * iframe.
  40. */
  41. const CHROME_EXTENSION_IFRAME_ERROR
  42. = 'Chrome Web Store installations can only be started by the top frame.';
  43. /**
  44. * The error message returned by chrome when the extension is installed.
  45. */
  46. const CHROME_NO_EXTENSION_ERROR_MSG // eslint-disable-line no-unused-vars
  47. = 'Could not establish connection. Receiving end does not exist.';
  48. /**
  49. * Handles obtaining a stream from a screen capture on different browsers.
  50. */
  51. const ScreenObtainer = {
  52. obtainStream: null,
  53. /**
  54. * Initializes the function used to obtain a screen capture
  55. * (this.obtainStream).
  56. *
  57. * @param options {object}
  58. * @param gum {Function} GUM method
  59. */
  60. init(options, gum) {
  61. let obtainDesktopStream = null;
  62. // eslint-disable-next-line no-param-reassign
  63. this.options = options = options || {};
  64. gumFunction = gum;
  65. if (RTCBrowserType.isFirefox()) {
  66. initFirefoxExtensionDetection(options);
  67. }
  68. if (RTCBrowserType.isNWJS()) {
  69. obtainDesktopStream = (_, onSuccess, onFailure) => {
  70. window.JitsiMeetNW.obtainDesktopStream(
  71. onSuccess,
  72. (error, constraints) => {
  73. let jitsiError;
  74. // FIXME:
  75. // This is very very durty fix for recognising that the
  76. // user have clicked the cancel button from the Desktop
  77. // sharing pick window. The proper solution would be to
  78. // detect this in the NWJS application by checking the
  79. // streamId === "". Even better solution would be to
  80. // stop calling GUM from the NWJS app and just pass the
  81. // streamId to lib-jitsi-meet. This way the desktop
  82. // sharing implementation for NWJS and chrome extension
  83. // will be the same and lib-jitsi-meet will be able to
  84. // control the constraints, check the streamId, etc.
  85. //
  86. // I cannot find documentation about "InvalidStateError"
  87. // but this is what we are receiving from GUM when the
  88. // streamId for the desktop sharing is "".
  89. if (error && error.name === 'InvalidStateError') {
  90. jitsiError = new JitsiTrackError(
  91. JitsiTrackErrors.CHROME_EXTENSION_USER_CANCELED
  92. );
  93. } else {
  94. jitsiError = new JitsiTrackError(
  95. error, constraints, [ 'desktop' ]);
  96. }
  97. (typeof onFailure === 'function')
  98. && onFailure(jitsiError);
  99. });
  100. };
  101. } else if (RTCBrowserType.isElectron()) {
  102. obtainDesktopStream = (_, onSuccess, onFailure) => {
  103. if (window.JitsiMeetScreenObtainer
  104. && window.JitsiMeetScreenObtainer.openDesktopPicker) {
  105. window.JitsiMeetScreenObtainer.openDesktopPicker(
  106. streamId =>
  107. onGetStreamResponse({ streamId },
  108. onSuccess,
  109. onFailure
  110. ),
  111. err => onFailure(new JitsiTrackError(
  112. JitsiTrackErrors.ELECTRON_DESKTOP_PICKER_ERROR,
  113. err
  114. ))
  115. );
  116. } else {
  117. onFailure(new JitsiTrackError(
  118. JitsiTrackErrors.ELECTRON_DESKTOP_PICKER_NOT_FOUND));
  119. }
  120. };
  121. } else if (RTCBrowserType.isTemasysPluginUsed()) {
  122. // XXX Don't require Temasys unless it's to be used because it
  123. // doesn't run on React Native, for example.
  124. const plugin
  125. = require('./adapter.screenshare').WebRTCPlugin.plugin;
  126. if (plugin.HasScreensharingFeature) {
  127. if (plugin.isScreensharingAvailable) {
  128. obtainDesktopStream = obtainWebRTCScreen;
  129. logger.info('Using Temasys plugin for desktop sharing');
  130. } else {
  131. logger.info(
  132. 'Screensharing not available with Temasys plugin on'
  133. + ' this site');
  134. }
  135. } else {
  136. logger.info(
  137. 'Screensharing not supported by this plugin version');
  138. }
  139. } else if (RTCBrowserType.isChrome()) {
  140. if (options.desktopSharingChromeDisabled
  141. || options.desktopSharingChromeMethod === false
  142. || !options.desktopSharingChromeExtId) {
  143. // TODO: desktopSharingChromeMethod is deprecated, remove.
  144. obtainDesktopStream = null;
  145. } else if (RTCBrowserType.getChromeVersion() >= 34) {
  146. obtainDesktopStream
  147. = this.obtainScreenFromExtension;
  148. logger.info('Using Chrome extension for desktop sharing');
  149. initChromeExtension(options);
  150. } else {
  151. logger.info('Chrome extension not supported until ver 34');
  152. }
  153. } else if (RTCBrowserType.isFirefox()) {
  154. if (options.desktopSharingFirefoxDisabled) {
  155. obtainDesktopStream = null;
  156. } else if (window.location.protocol === 'http:') {
  157. logger.log('Screen sharing is not supported over HTTP. '
  158. + 'Use of HTTPS is required.');
  159. obtainDesktopStream = null;
  160. } else {
  161. obtainDesktopStream = this.obtainScreenOnFirefox;
  162. }
  163. }
  164. if (!obtainDesktopStream) {
  165. logger.info('Desktop sharing disabled');
  166. }
  167. this.obtainStream = obtainDesktopStream;
  168. },
  169. /**
  170. * Checks whether obtaining a screen capture is supported in the current
  171. * environment.
  172. * @returns {boolean}
  173. */
  174. isSupported() {
  175. return this.obtainStream !== null;
  176. },
  177. /**
  178. * Obtains a screen capture stream on Firefox.
  179. * @param callback
  180. * @param errorCallback
  181. */
  182. obtainScreenOnFirefox(options, callback, errorCallback) {
  183. let extensionRequired = false;
  184. const { desktopSharingFirefoxMaxVersionExtRequired } = this.options;
  185. if (desktopSharingFirefoxMaxVersionExtRequired === -1
  186. || (desktopSharingFirefoxMaxVersionExtRequired >= 0
  187. && RTCBrowserType.getFirefoxVersion()
  188. <= desktopSharingFirefoxMaxVersionExtRequired)) {
  189. extensionRequired = true;
  190. logger.log(
  191. `Jidesha extension required on firefox version ${
  192. RTCBrowserType.getFirefoxVersion()}`);
  193. }
  194. if (!extensionRequired || firefoxExtInstalled === true) {
  195. obtainWebRTCScreen(options, callback, errorCallback);
  196. return;
  197. }
  198. if (reDetectFirefoxExtension) {
  199. reDetectFirefoxExtension = false;
  200. initFirefoxExtensionDetection(this.options);
  201. }
  202. // Give it some (more) time to initialize, and assume lack of
  203. // extension if it hasn't.
  204. if (firefoxExtInstalled === null) {
  205. window.setTimeout(
  206. () => {
  207. if (firefoxExtInstalled === null) {
  208. firefoxExtInstalled = false;
  209. }
  210. this.obtainScreenOnFirefox(callback, errorCallback);
  211. },
  212. 300);
  213. logger.log(
  214. 'Waiting for detection of jidesha on firefox to finish.');
  215. return;
  216. }
  217. // We need an extension and it isn't installed.
  218. // Make sure we check for the extension when the user clicks again.
  219. firefoxExtInstalled = null;
  220. reDetectFirefoxExtension = true;
  221. // Make sure desktopsharing knows that we failed, so that it doesn't get
  222. // stuck in 'switching' mode.
  223. errorCallback(
  224. new JitsiTrackError(JitsiTrackErrors.FIREFOX_EXTENSION_NEEDED));
  225. },
  226. /**
  227. * Asks Chrome extension to call chooseDesktopMedia and gets chrome
  228. * 'desktop' stream for returned stream token.
  229. */
  230. obtainScreenFromExtension(options, streamCallback, failCallback) {
  231. if (chromeExtInstalled) {
  232. doGetStreamFromExtension(this.options, streamCallback,
  233. failCallback);
  234. } else {
  235. if (chromeExtUpdateRequired) {
  236. /* eslint-disable no-alert */
  237. alert(
  238. 'Jitsi Desktop Streamer requires update. '
  239. + 'Changes will take effect after next Chrome restart.');
  240. /* eslint-enable no-alert */
  241. }
  242. try {
  243. chrome.webstore.install(
  244. getWebStoreInstallUrl(this.options),
  245. arg => {
  246. logger.log('Extension installed successfully', arg);
  247. chromeExtInstalled = true;
  248. // We need to give a moment to the endpoint to become
  249. // available.
  250. waitForExtensionAfterInstall(this.options, 200, 10)
  251. .then(() => {
  252. doGetStreamFromExtension(this.options,
  253. streamCallback, failCallback);
  254. })
  255. .catch(() => {
  256. this.handleExtensionInstallationError(options,
  257. streamCallback, failCallback);
  258. });
  259. },
  260. this.handleExtensionInstallationError.bind(this,
  261. options, streamCallback, failCallback)
  262. );
  263. } catch (e) {
  264. this.handleExtensionInstallationError(options, streamCallback,
  265. failCallback, e);
  266. }
  267. }
  268. },
  269. /* eslint-disable max-params */
  270. handleExtensionInstallationError(options, streamCallback, failCallback, e) {
  271. const webStoreInstallUrl = getWebStoreInstallUrl(this.options);
  272. if ((CHROME_EXTENSION_POPUP_ERROR === e
  273. || CHROME_EXTENSION_IFRAME_ERROR === e)
  274. && options.interval > 0
  275. && typeof options.checkAgain === 'function'
  276. && typeof options.listener === 'function') {
  277. options.listener('waitingForExtension', webStoreInstallUrl);
  278. this.checkForChromeExtensionOnInterval(options, streamCallback,
  279. failCallback, e);
  280. return;
  281. }
  282. const msg
  283. = `Failed to install the extension from ${webStoreInstallUrl}`;
  284. logger.log(msg, e);
  285. failCallback(new JitsiTrackError(
  286. JitsiTrackErrors.CHROME_EXTENSION_INSTALLATION_ERROR,
  287. msg));
  288. },
  289. /* eslint-enable max-params */
  290. checkForChromeExtensionOnInterval(options, streamCallback, failCallback) {
  291. if (options.checkAgain() === false) {
  292. failCallback(new JitsiTrackError(
  293. JitsiTrackErrors.CHROME_EXTENSION_INSTALLATION_ERROR));
  294. return;
  295. }
  296. waitForExtensionAfterInstall(this.options, options.interval, 1)
  297. .then(() => {
  298. chromeExtInstalled = true;
  299. options.listener('extensionFound');
  300. this.obtainScreenFromExtension(options,
  301. streamCallback, failCallback);
  302. })
  303. .catch(() => {
  304. this.checkForChromeExtensionOnInterval(options,
  305. streamCallback, failCallback);
  306. });
  307. }
  308. };
  309. /**
  310. * Obtains a desktop stream using getUserMedia.
  311. * For this to work on Chrome, the
  312. * 'chrome://flags/#enable-usermedia-screen-capture' flag must be enabled.
  313. *
  314. * On firefox, the document's domain must be white-listed in the
  315. * 'media.getusermedia.screensharing.allowed_domains' preference in
  316. * 'about:config'.
  317. */
  318. function obtainWebRTCScreen(options, streamCallback, failCallback) {
  319. gumFunction([ 'screen' ], streamCallback, failCallback);
  320. }
  321. /**
  322. * Constructs inline install URL for Chrome desktop streaming extension.
  323. * The 'chromeExtensionId' must be defined in options parameter.
  324. * @param options supports "desktopSharingChromeExtId"
  325. * @returns {string}
  326. */
  327. function getWebStoreInstallUrl(options) {
  328. return (
  329. `https://chrome.google.com/webstore/detail/${
  330. options.desktopSharingChromeExtId}`);
  331. }
  332. /**
  333. * Checks whether an update of the Chrome extension is required.
  334. * @param minVersion minimal required version
  335. * @param extVersion current extension version
  336. * @returns {boolean}
  337. */
  338. function isUpdateRequired(minVersion, extVersion) {
  339. try {
  340. const s1 = minVersion.split('.');
  341. const s2 = extVersion.split('.');
  342. const len = Math.max(s1.length, s2.length);
  343. for (let i = 0; i < len; i++) {
  344. let n1 = 0,
  345. n2 = 0;
  346. if (i < s1.length) {
  347. n1 = parseInt(s1[i], 10);
  348. }
  349. if (i < s2.length) {
  350. n2 = parseInt(s2[i], 10);
  351. }
  352. if (isNaN(n1) || isNaN(n2)) {
  353. return true;
  354. } else if (n1 !== n2) {
  355. return n1 > n2;
  356. }
  357. }
  358. // will happen if both versions have identical numbers in
  359. // their components (even if one of them is longer, has more components)
  360. return false;
  361. } catch (e) {
  362. GlobalOnErrorHandler.callErrorHandler(e);
  363. logger.error('Failed to parse extension version', e);
  364. return true;
  365. }
  366. }
  367. /**
  368. *
  369. * @param callback
  370. * @param options
  371. */
  372. function checkChromeExtInstalled(callback, options) {
  373. if (typeof chrome === 'undefined' || !chrome || !chrome.runtime) {
  374. // No API, so no extension for sure
  375. callback(false, false);
  376. return;
  377. }
  378. chrome.runtime.sendMessage(
  379. options.desktopSharingChromeExtId,
  380. { getVersion: true },
  381. response => {
  382. if (!response || !response.version) {
  383. // Communication failure - assume that no endpoint exists
  384. logger.warn(
  385. 'Extension not installed?: ', chrome.runtime.lastError);
  386. callback(false, false);
  387. return;
  388. }
  389. // Check installed extension version
  390. const extVersion = response.version;
  391. logger.log(`Extension version is: ${extVersion}`);
  392. const updateRequired
  393. = isUpdateRequired(
  394. options.desktopSharingChromeMinExtVersion,
  395. extVersion);
  396. callback(!updateRequired, updateRequired);
  397. }
  398. );
  399. }
  400. /**
  401. *
  402. * @param options
  403. * @param streamCallback
  404. * @param failCallback
  405. */
  406. function doGetStreamFromExtension(options, streamCallback, failCallback) {
  407. // Sends 'getStream' msg to the extension.
  408. // Extension id must be defined in the config.
  409. chrome.runtime.sendMessage(
  410. options.desktopSharingChromeExtId,
  411. {
  412. getStream: true,
  413. sources: options.desktopSharingChromeSources
  414. },
  415. response => {
  416. if (!response) {
  417. // possibly re-wraping error message to make code consistent
  418. const lastError = chrome.runtime.lastError;
  419. failCallback(lastError instanceof Error
  420. ? lastError
  421. : new JitsiTrackError(
  422. JitsiTrackErrors.CHROME_EXTENSION_GENERIC_ERROR,
  423. lastError));
  424. return;
  425. }
  426. logger.log('Response from extension: ', response);
  427. onGetStreamResponse(response, streamCallback, failCallback);
  428. }
  429. );
  430. }
  431. /**
  432. * Initializes <link rel=chrome-webstore-item /> with extension id set in
  433. * config.js to support inline installs. Host site must be selected as main
  434. * website of published extension.
  435. * @param options supports "desktopSharingChromeExtId"
  436. */
  437. function initInlineInstalls(options) {
  438. if ($('link[rel=chrome-webstore-item]').length === 0) {
  439. $('head').append('<link rel="chrome-webstore-item">');
  440. }
  441. $('link[rel=chrome-webstore-item]').attr('href',
  442. getWebStoreInstallUrl(options));
  443. }
  444. /**
  445. *
  446. * @param options
  447. */
  448. function initChromeExtension(options) {
  449. // Initialize Chrome extension inline installs
  450. initInlineInstalls(options);
  451. // Check if extension is installed
  452. checkChromeExtInstalled((installed, updateRequired) => {
  453. chromeExtInstalled = installed;
  454. chromeExtUpdateRequired = updateRequired;
  455. logger.info(
  456. `Chrome extension installed: ${chromeExtInstalled
  457. } updateRequired: ${chromeExtUpdateRequired}`);
  458. }, options);
  459. }
  460. /**
  461. * Checks "retries" times on every "waitInterval"ms whether the ext is alive.
  462. * @param {Object} options the options passed to ScreanObtainer.obtainStream
  463. * @param {int} waitInterval the number of ms between retries
  464. * @param {int} retries the number of retries
  465. * @returns {Promise} returns promise that will be resolved when the extension
  466. * is alive and rejected if the extension is not alive even after "retries"
  467. * checks
  468. */
  469. function waitForExtensionAfterInstall(options, waitInterval, retries) {
  470. if (retries === 0) {
  471. return Promise.reject();
  472. }
  473. return new Promise((resolve, reject) => {
  474. let currentRetries = retries;
  475. const interval = window.setInterval(() => {
  476. checkChromeExtInstalled(installed => {
  477. if (installed) {
  478. window.clearInterval(interval);
  479. resolve();
  480. } else {
  481. currentRetries--;
  482. if (currentRetries === 0) {
  483. reject();
  484. window.clearInterval(interval);
  485. }
  486. }
  487. }, options);
  488. }, waitInterval);
  489. });
  490. }
  491. /**
  492. * Handles response from external application / extension and calls GUM to
  493. * receive the desktop streams or reports error.
  494. * @param {object} response
  495. * @param {string} response.streamId - the streamId for the desktop stream
  496. * @param {string} response.error - error to be reported.
  497. * @param {Function} onSuccess - callback for success.
  498. * @param {Function} onFailure - callback for failure.
  499. */
  500. function onGetStreamResponse(response, onSuccess, onFailure) {
  501. if (response.streamId) {
  502. gumFunction(
  503. [ 'desktop' ],
  504. stream => onSuccess(stream),
  505. onFailure,
  506. { desktopStream: response.streamId });
  507. } else {
  508. // As noted in Chrome Desktop Capture API:
  509. // If user didn't select any source (i.e. canceled the prompt)
  510. // then the callback is called with an empty streamId.
  511. if (response.streamId === '') {
  512. onFailure(new JitsiTrackError(
  513. JitsiTrackErrors.CHROME_EXTENSION_USER_CANCELED));
  514. return;
  515. }
  516. onFailure(new JitsiTrackError(
  517. JitsiTrackErrors.CHROME_EXTENSION_GENERIC_ERROR,
  518. response.error));
  519. }
  520. }
  521. /**
  522. * Starts the detection of an installed jidesha extension for firefox.
  523. * @param options supports "desktopSharingFirefoxDisabled",
  524. * "desktopSharingFirefoxExtId"
  525. */
  526. function initFirefoxExtensionDetection(options) {
  527. if (options.desktopSharingFirefoxDisabled) {
  528. return;
  529. }
  530. if (firefoxExtInstalled === false || firefoxExtInstalled === true) {
  531. return;
  532. }
  533. if (!options.desktopSharingFirefoxExtId) {
  534. firefoxExtInstalled = false;
  535. return;
  536. }
  537. const img = document.createElement('img');
  538. img.onload = () => {
  539. logger.log('Detected firefox screen sharing extension.');
  540. firefoxExtInstalled = true;
  541. };
  542. img.onerror = () => {
  543. logger.log('Detected lack of firefox screen sharing extension.');
  544. firefoxExtInstalled = false;
  545. };
  546. // The jidesha extension exposes an empty image file under the url:
  547. // "chrome://EXT_ID/content/DOMAIN.png"
  548. // Where EXT_ID is the ID of the extension with "@" replaced by ".", and
  549. // DOMAIN is a domain whitelisted by the extension.
  550. const src
  551. = `chrome://${options.desktopSharingFirefoxExtId.replace('@', '.')
  552. }/content/${document.location.hostname}.png`;
  553. img.setAttribute('src', src);
  554. }
  555. export default ScreenObtainer;