Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

ScreenObtainer.js 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  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 message returned by chrome when the extension is installed.
  21. */
  22. const CHROME_NO_EXTENSION_ERROR_MSG // eslint-disable-line no-unused-vars
  23. = 'Could not establish connection. Receiving end does not exist.';
  24. /**
  25. * Handles obtaining a stream from a screen capture on different browsers.
  26. */
  27. const ScreenObtainer = {
  28. /**
  29. * If not <tt>null</tt> it means that the initialization process is still in
  30. * progress. It is used to make desktop stream request wait and continue
  31. * after it's done.
  32. * {@type Promise|null}
  33. */
  34. intChromeExtPromise: null,
  35. obtainStream: null,
  36. /**
  37. * Initializes the function used to obtain a screen capture
  38. * (this.obtainStream).
  39. *
  40. * @param {object} options
  41. * @param {boolean} [options.desktopSharingChromeDisabled]
  42. * @param {boolean} [options.desktopSharingChromeExtId]
  43. * @param {boolean} [options.desktopSharingFirefoxDisabled]
  44. * @param {Function} gum GUM method
  45. */
  46. init(options = {
  47. desktopSharingChromeDisabled: false,
  48. desktopSharingChromeExtId: null,
  49. desktopSharingFirefoxDisabled: false
  50. }, gum) {
  51. this.options = options;
  52. gumFunction = gum;
  53. this.obtainStream = this._createObtainStreamMethod(options);
  54. if (!this.obtainStream) {
  55. logger.info('Desktop sharing disabled');
  56. }
  57. },
  58. /**
  59. * Returns a method which will be used to obtain the screen sharing stream
  60. * (based on the browser type).
  61. *
  62. * @param {object} options passed from {@link init} - check description
  63. * there
  64. * @returns {Function}
  65. * @private
  66. */
  67. _createObtainStreamMethod(options) {
  68. if (browser.isNWJS()) {
  69. return (_, onSuccess, onFailure) => {
  70. window.JitsiMeetNW.obtainDesktopStream(
  71. onSuccess,
  72. (error, constraints) => {
  73. let jitsiError;
  74. // FIXME:
  75. // This is very very dirty 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 (browser.isElectron()) {
  102. return this.obtainScreenOnElectron;
  103. } else if (browser.isChrome() || browser.isOpera()) {
  104. if (browser.supportsGetDisplayMedia()
  105. && !options.desktopSharingChromeDisabled) {
  106. return this.obtainScreenFromGetDisplayMedia;
  107. } else if (options.desktopSharingChromeDisabled
  108. || !options.desktopSharingChromeExtId) {
  109. return null;
  110. }
  111. logger.info('Using Chrome extension for desktop sharing');
  112. this.intChromeExtPromise
  113. = initChromeExtension(options).then(() => {
  114. this.intChromeExtPromise = null;
  115. });
  116. return this.obtainScreenFromExtension;
  117. } else if (browser.isFirefox()) {
  118. if (options.desktopSharingFirefoxDisabled) {
  119. return null;
  120. } else if (browser.supportsGetDisplayMedia()) {
  121. // Firefox 66 support getDisplayMedia
  122. return this.obtainScreenFromGetDisplayMedia;
  123. }
  124. // Legacy Firefox
  125. return this.obtainScreenOnFirefox;
  126. } else if (browser.isSafari() && browser.supportsGetDisplayMedia()) {
  127. return this.obtainScreenFromGetDisplayMedia;
  128. }
  129. logger.log(
  130. 'Screen sharing not supported by the current browser: ',
  131. browser.getName());
  132. return null;
  133. },
  134. /**
  135. * Checks whether obtaining a screen capture is supported in the current
  136. * environment.
  137. * @returns {boolean}
  138. */
  139. isSupported() {
  140. return this.obtainStream !== null;
  141. },
  142. /**
  143. * Obtains a screen capture stream on Firefox.
  144. * @param callback
  145. * @param errorCallback
  146. */
  147. obtainScreenOnFirefox(options, callback, errorCallback) {
  148. obtainWebRTCScreen(options.gumOptions, callback, errorCallback);
  149. },
  150. /**
  151. * Obtains a screen capture stream on Electron.
  152. *
  153. * @param {Object} [options] - Screen sharing options.
  154. * @param {Array<string>} [options.desktopSharingSources] - Array with the
  155. * sources that have to be displayed in the desktop picker window ('screen',
  156. * 'window', etc.).
  157. * @param onSuccess - Success callback.
  158. * @param onFailure - Failure callback.
  159. */
  160. obtainScreenOnElectron(options = {}, onSuccess, onFailure) {
  161. if (window.JitsiMeetScreenObtainer
  162. && window.JitsiMeetScreenObtainer.openDesktopPicker) {
  163. const { desktopSharingSources, gumOptions } = options;
  164. window.JitsiMeetScreenObtainer.openDesktopPicker(
  165. {
  166. desktopSharingSources: desktopSharingSources
  167. || this.options.desktopSharingChromeSources
  168. },
  169. (streamId, streamType, screenShareAudio = false) =>
  170. onGetStreamResponse(
  171. {
  172. response: {
  173. streamId,
  174. streamType,
  175. screenShareAudio
  176. },
  177. gumOptions
  178. },
  179. onSuccess,
  180. onFailure
  181. ),
  182. err => onFailure(new JitsiTrackError(
  183. JitsiTrackErrors.ELECTRON_DESKTOP_PICKER_ERROR,
  184. err
  185. ))
  186. );
  187. } else {
  188. onFailure(new JitsiTrackError(
  189. JitsiTrackErrors.ELECTRON_DESKTOP_PICKER_NOT_FOUND));
  190. }
  191. },
  192. /**
  193. * Asks Chrome extension to call chooseDesktopMedia and gets chrome
  194. * 'desktop' stream for returned stream token.
  195. */
  196. obtainScreenFromExtension(options, streamCallback, failCallback) {
  197. if (this.intChromeExtPromise !== null) {
  198. this.intChromeExtPromise.then(() => {
  199. this.obtainScreenFromExtension(
  200. options, streamCallback, failCallback);
  201. });
  202. return;
  203. }
  204. const {
  205. desktopSharingChromeExtId,
  206. desktopSharingChromeSources
  207. } = this.options;
  208. const {
  209. gumOptions
  210. } = options;
  211. const doGetStreamFromExtensionOptions = {
  212. desktopSharingChromeExtId,
  213. desktopSharingChromeSources:
  214. options.desktopSharingSources || desktopSharingChromeSources,
  215. gumOptions
  216. };
  217. if (chromeExtInstalled) {
  218. doGetStreamFromExtension(
  219. doGetStreamFromExtensionOptions,
  220. streamCallback,
  221. failCallback);
  222. } else {
  223. if (chromeExtUpdateRequired) {
  224. /* eslint-disable no-alert */
  225. alert(
  226. 'Jitsi Desktop Streamer requires update. '
  227. + 'Changes will take effect after next Chrome restart.');
  228. /* eslint-enable no-alert */
  229. }
  230. this.handleExternalInstall(options, streamCallback,
  231. failCallback);
  232. }
  233. },
  234. /* eslint-disable max-params */
  235. handleExternalInstall(options, streamCallback, failCallback, e) {
  236. const webStoreInstallUrl = getWebStoreInstallUrl(this.options);
  237. options.listener('waitingForExtension', webStoreInstallUrl);
  238. this.checkForChromeExtensionOnInterval(options, streamCallback,
  239. failCallback, e);
  240. },
  241. /* eslint-enable max-params */
  242. checkForChromeExtensionOnInterval(options, streamCallback, failCallback) {
  243. if (options.checkAgain() === false) {
  244. failCallback(new JitsiTrackError(
  245. JitsiTrackErrors.CHROME_EXTENSION_INSTALLATION_ERROR));
  246. return;
  247. }
  248. waitForExtensionAfterInstall(this.options, options.interval, 1)
  249. .then(() => {
  250. chromeExtInstalled = true;
  251. options.listener('extensionFound');
  252. this.obtainScreenFromExtension(options,
  253. streamCallback, failCallback);
  254. })
  255. .catch(() => {
  256. this.checkForChromeExtensionOnInterval(options,
  257. streamCallback, failCallback);
  258. });
  259. },
  260. /**
  261. * Obtains a screen capture stream using getDisplayMedia.
  262. *
  263. * @param callback - The success callback.
  264. * @param errorCallback - The error callback.
  265. */
  266. obtainScreenFromGetDisplayMedia(options, callback, errorCallback) {
  267. logger.info('Using getDisplayMedia for screen sharing');
  268. let getDisplayMedia;
  269. if (navigator.getDisplayMedia) {
  270. getDisplayMedia = navigator.getDisplayMedia.bind(navigator);
  271. } else {
  272. // eslint-disable-next-line max-len
  273. getDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices);
  274. }
  275. getDisplayMedia({ video: true,
  276. audio: true })
  277. .then(stream => {
  278. let applyConstraintsPromise;
  279. if (stream
  280. && stream.getTracks()
  281. && stream.getTracks().length > 0) {
  282. const videoTrack = stream.getVideoTracks()[0];
  283. // Apply video track constraint.
  284. if (videoTrack) {
  285. applyConstraintsPromise = videoTrack.applyConstraints(options.trackOptions);
  286. }
  287. } else {
  288. applyConstraintsPromise = Promise.resolve();
  289. }
  290. applyConstraintsPromise.then(() =>
  291. callback({
  292. stream,
  293. sourceId: stream.id
  294. }));
  295. })
  296. .catch(() =>
  297. errorCallback(new JitsiTrackError(JitsiTrackErrors
  298. .CHROME_EXTENSION_USER_CANCELED)));
  299. }
  300. };
  301. /**
  302. * Obtains a desktop stream using getUserMedia.
  303. * For this to work on Chrome, the
  304. * 'chrome://flags/#enable-usermedia-screen-capture' flag must be enabled.
  305. *
  306. * On firefox, the document's domain must be white-listed in the
  307. * 'media.getusermedia.screensharing.allowed_domains' preference in
  308. * 'about:config'.
  309. */
  310. function obtainWebRTCScreen(options, streamCallback, failCallback) {
  311. gumFunction([ 'screen' ], options)
  312. .then(stream => streamCallback({ stream }), failCallback);
  313. }
  314. /**
  315. * Constructs inline install URL for Chrome desktop streaming extension.
  316. * The 'chromeExtensionId' must be defined in options parameter.
  317. * @param options supports "desktopSharingChromeExtId"
  318. * @returns {string}
  319. */
  320. function getWebStoreInstallUrl(options) {
  321. return (
  322. `https://chrome.google.com/webstore/detail/${
  323. options.desktopSharingChromeExtId}`);
  324. }
  325. /**
  326. * Checks whether an update of the Chrome extension is required.
  327. * @param minVersion minimal required version
  328. * @param extVersion current extension version
  329. * @returns {boolean}
  330. */
  331. function isUpdateRequired(minVersion, extVersion) {
  332. try {
  333. const s1 = minVersion.split('.');
  334. const s2 = extVersion.split('.');
  335. const len = Math.max(s1.length, s2.length);
  336. for (let i = 0; i < len; i++) {
  337. let n1 = 0,
  338. n2 = 0;
  339. if (i < s1.length) {
  340. n1 = parseInt(s1[i], 10);
  341. }
  342. if (i < s2.length) {
  343. n2 = parseInt(s2[i], 10);
  344. }
  345. if (isNaN(n1) || isNaN(n2)) {
  346. return true;
  347. } else if (n1 !== n2) {
  348. return n1 > n2;
  349. }
  350. }
  351. // will happen if both versions have identical numbers in
  352. // their components (even if one of them is longer, has more components)
  353. return false;
  354. } catch (e) {
  355. GlobalOnErrorHandler.callErrorHandler(e);
  356. logger.error('Failed to parse extension version', e);
  357. return true;
  358. }
  359. }
  360. /**
  361. *
  362. * @param callback
  363. * @param options
  364. */
  365. function checkChromeExtInstalled(callback, options) {
  366. if (typeof chrome === 'undefined' || !chrome || !chrome.runtime) {
  367. // No API, so no extension for sure
  368. callback(false, false);
  369. return;
  370. }
  371. chrome.runtime.sendMessage(
  372. options.desktopSharingChromeExtId,
  373. { getVersion: true },
  374. response => {
  375. if (!response || !response.version) {
  376. // Communication failure - assume that no endpoint exists
  377. logger.warn(
  378. 'Extension not installed?: ', chrome.runtime.lastError);
  379. callback(false, false);
  380. return;
  381. }
  382. // Check installed extension version
  383. const extVersion = response.version;
  384. logger.log(`Extension version is: ${extVersion}`);
  385. const updateRequired
  386. = isUpdateRequired(
  387. options.desktopSharingChromeMinExtVersion,
  388. extVersion);
  389. callback(!updateRequired, updateRequired);
  390. }
  391. );
  392. }
  393. /**
  394. *
  395. * @param options
  396. * @param streamCallback
  397. * @param failCallback
  398. */
  399. function doGetStreamFromExtension(options, streamCallback, failCallback) {
  400. const {
  401. desktopSharingChromeSources,
  402. desktopSharingChromeExtId,
  403. gumOptions
  404. } = options;
  405. // Sends 'getStream' msg to the extension.
  406. // Extension id must be defined in the config.
  407. chrome.runtime.sendMessage(
  408. desktopSharingChromeExtId,
  409. {
  410. getStream: true,
  411. sources: desktopSharingChromeSources
  412. },
  413. response => {
  414. if (!response) {
  415. // possibly re-wraping error message to make code consistent
  416. const lastError = chrome.runtime.lastError;
  417. failCallback(lastError instanceof Error
  418. ? lastError
  419. : new JitsiTrackError(
  420. JitsiTrackErrors.CHROME_EXTENSION_GENERIC_ERROR,
  421. lastError));
  422. return;
  423. }
  424. logger.log('Response from extension: ', response);
  425. onGetStreamResponse(
  426. {
  427. response,
  428. gumOptions
  429. },
  430. streamCallback,
  431. failCallback
  432. );
  433. }
  434. );
  435. }
  436. /**
  437. * Initializes <link rel=chrome-webstore-item /> with extension id set in
  438. * config.js to support inline installs. Host site must be selected as main
  439. * website of published extension.
  440. * @param options supports "desktopSharingChromeExtId"
  441. */
  442. function initInlineInstalls(options) {
  443. if ($('link[rel=chrome-webstore-item]').length === 0) {
  444. $('head').append('<link rel="chrome-webstore-item">');
  445. }
  446. $('link[rel=chrome-webstore-item]').attr('href',
  447. getWebStoreInstallUrl(options));
  448. }
  449. /**
  450. *
  451. * @param options
  452. *
  453. * @return {Promise} - a Promise resolved once the initialization process is
  454. * finished.
  455. */
  456. function initChromeExtension(options) {
  457. // Initialize Chrome extension inline installs
  458. initInlineInstalls(options);
  459. return new Promise(resolve => {
  460. // Check if extension is installed
  461. checkChromeExtInstalled((installed, updateRequired) => {
  462. chromeExtInstalled = installed;
  463. chromeExtUpdateRequired = updateRequired;
  464. logger.info(
  465. `Chrome extension installed: ${
  466. chromeExtInstalled} updateRequired: ${
  467. chromeExtUpdateRequired}`);
  468. resolve();
  469. }, options);
  470. });
  471. }
  472. /**
  473. * Checks "retries" times on every "waitInterval"ms whether the ext is alive.
  474. * @param {Object} options the options passed to ScreanObtainer.obtainStream
  475. * @param {int} waitInterval the number of ms between retries
  476. * @param {int} retries the number of retries
  477. * @returns {Promise} returns promise that will be resolved when the extension
  478. * is alive and rejected if the extension is not alive even after "retries"
  479. * checks
  480. */
  481. function waitForExtensionAfterInstall(options, waitInterval, retries) {
  482. if (retries === 0) {
  483. return Promise.reject();
  484. }
  485. return new Promise((resolve, reject) => {
  486. let currentRetries = retries;
  487. const interval = window.setInterval(() => {
  488. checkChromeExtInstalled(installed => {
  489. if (installed) {
  490. window.clearInterval(interval);
  491. resolve();
  492. } else {
  493. currentRetries--;
  494. if (currentRetries === 0) {
  495. reject();
  496. window.clearInterval(interval);
  497. }
  498. }
  499. }, options);
  500. }, waitInterval);
  501. });
  502. }
  503. /**
  504. * Handles response from external application / extension and calls GUM to
  505. * receive the desktop streams or reports error.
  506. * @param {object} options
  507. * @param {object} options.response
  508. * @param {string} options.response.streamId - the streamId for the desktop
  509. * stream.
  510. * @param {bool} options.response.screenShareAudio - Used by electron clients to
  511. * enable system audio screen sharing.
  512. * @param {string} options.response.error - error to be reported.
  513. * @param {object} options.gumOptions - options passed to GUM.
  514. * @param {Function} onSuccess - callback for success.
  515. * @param {Function} onFailure - callback for failure.
  516. * @param {object} gumOptions - options passed to GUM.
  517. */
  518. function onGetStreamResponse(
  519. options = {
  520. response: {},
  521. gumOptions: {}
  522. },
  523. onSuccess,
  524. onFailure) {
  525. const { streamId, streamType, screenShareAudio, error } = options.response || {};
  526. if (streamId) {
  527. const gumOptions = {
  528. desktopStream: streamId,
  529. screenShareAudio,
  530. ...options.gumOptions
  531. };
  532. gumFunction([ 'desktop' ], gumOptions)
  533. .then(stream => onSuccess({
  534. stream,
  535. sourceId: streamId,
  536. sourceType: streamType
  537. }), onFailure);
  538. } else {
  539. // As noted in Chrome Desktop Capture API:
  540. // If user didn't select any source (i.e. canceled the prompt)
  541. // then the callback is called with an empty streamId.
  542. if (streamId === '') {
  543. onFailure(new JitsiTrackError(
  544. JitsiTrackErrors.CHROME_EXTENSION_USER_CANCELED));
  545. return;
  546. }
  547. onFailure(new JitsiTrackError(
  548. JitsiTrackErrors.CHROME_EXTENSION_GENERIC_ERROR,
  549. error));
  550. }
  551. }
  552. export default ScreenObtainer;