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 23KB

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