api-media-handler-spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. import { expect } from 'chai';
  2. import { BrowserWindow, session, desktopCapturer } from 'electron/main';
  3. import { closeAllWindows } from './lib/window-helpers';
  4. import * as http from 'node:http';
  5. import { ifit, listen } from './lib/spec-helpers';
  6. describe('setDisplayMediaRequestHandler', () => {
  7. afterEach(closeAllWindows);
  8. // These tests are done on an http server because navigator.userAgentData
  9. // requires a secure context.
  10. let server: http.Server;
  11. let serverUrl: string;
  12. before(async () => {
  13. server = http.createServer((req, res) => {
  14. res.setHeader('Content-Type', 'text/html');
  15. res.end('');
  16. });
  17. serverUrl = (await listen(server)).url;
  18. });
  19. after(() => {
  20. server.close();
  21. });
  22. // FIXME(nornagon): this test fails on our macOS CircleCI runners with the
  23. // error message:
  24. // [ERROR:video_capture_device_client.cc(659)] error@ OnStart@content/browser/media/capture/desktop_capture_device_mac.cc:98, CGDisplayStreamCreate failed, OS message: Value too large to be stored in data type (84)
  25. // This is possibly related to the OS/VM setup that CircleCI uses for macOS.
  26. // Our arm64 runners are in @jkleinsc's office, and are real machines, so the
  27. // test works there.
  28. ifit(!(process.platform === 'darwin' && process.arch === 'x64'))('works when calling getDisplayMedia', async function () {
  29. if ((await desktopCapturer.getSources({ types: ['screen'] })).length === 0) {
  30. return this.skip();
  31. }
  32. const ses = session.fromPartition('' + Math.random());
  33. let requestHandlerCalled = false;
  34. let mediaRequest: any = null;
  35. ses.setDisplayMediaRequestHandler((request, callback) => {
  36. requestHandlerCalled = true;
  37. mediaRequest = request;
  38. desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
  39. // Grant access to the first screen found.
  40. const { id, name } = sources[0];
  41. callback({
  42. video: { id, name }
  43. // TODO: 'loopback' and 'loopbackWithMute' are currently only supported on Windows.
  44. // audio: { id: 'loopback', name: 'System Audio' }
  45. });
  46. });
  47. });
  48. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  49. await w.loadURL(serverUrl);
  50. const { ok, message } = await w.webContents.executeJavaScript(`
  51. navigator.mediaDevices.getDisplayMedia({
  52. video: true,
  53. audio: false,
  54. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  55. `, true);
  56. expect(requestHandlerCalled).to.be.true();
  57. expect(mediaRequest.videoRequested).to.be.true();
  58. expect(mediaRequest.audioRequested).to.be.false();
  59. expect(ok).to.be.true(message);
  60. });
  61. it('does not crash when using a bogus ID', async () => {
  62. const ses = session.fromPartition('' + Math.random());
  63. let requestHandlerCalled = false;
  64. ses.setDisplayMediaRequestHandler((request, callback) => {
  65. requestHandlerCalled = true;
  66. callback({
  67. video: { id: 'bogus', name: 'whatever' }
  68. });
  69. });
  70. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  71. await w.loadURL(serverUrl);
  72. const { ok, message } = await w.webContents.executeJavaScript(`
  73. navigator.mediaDevices.getDisplayMedia({
  74. video: true,
  75. audio: true,
  76. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  77. `, true);
  78. expect(requestHandlerCalled).to.be.true();
  79. expect(ok).to.be.false();
  80. expect(message).to.equal('Could not start video source');
  81. });
  82. it('successfully returns a capture handle', async () => {
  83. let w: BrowserWindow | null = null;
  84. const ses = session.fromPartition('' + Math.random());
  85. let requestHandlerCalled = false;
  86. let mediaRequest: any = null;
  87. ses.setDisplayMediaRequestHandler((request, callback) => {
  88. requestHandlerCalled = true;
  89. mediaRequest = request;
  90. callback({ video: w?.webContents.mainFrame });
  91. });
  92. w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  93. await w.loadURL(serverUrl);
  94. const { ok, handleID, captureHandle, message } = await w.webContents.executeJavaScript(`
  95. const handleID = crypto.randomUUID();
  96. navigator.mediaDevices.setCaptureHandleConfig({
  97. handle: handleID,
  98. exposeOrigin: true,
  99. permittedOrigins: ["*"],
  100. });
  101. navigator.mediaDevices.getDisplayMedia({
  102. video: true,
  103. audio: false
  104. }).then(stream => {
  105. const [videoTrack] = stream.getVideoTracks();
  106. const captureHandle = videoTrack.getCaptureHandle();
  107. return { ok: true, handleID, captureHandle, message: null }
  108. }, e => ({ ok: false, message: e.message }))
  109. `, true);
  110. expect(requestHandlerCalled).to.be.true();
  111. expect(mediaRequest.videoRequested).to.be.true();
  112. expect(mediaRequest.audioRequested).to.be.false();
  113. expect(ok).to.be.true();
  114. expect(captureHandle.handle).to.be.a('string');
  115. expect(handleID).to.eq(captureHandle.handle);
  116. expect(message).to.be.null();
  117. });
  118. it('does not crash when providing only audio for a video request', async () => {
  119. const ses = session.fromPartition('' + Math.random());
  120. let requestHandlerCalled = false;
  121. let callbackError: any;
  122. ses.setDisplayMediaRequestHandler((request, callback) => {
  123. requestHandlerCalled = true;
  124. try {
  125. callback({
  126. audio: 'loopback'
  127. });
  128. } catch (e) {
  129. callbackError = e;
  130. }
  131. });
  132. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  133. await w.loadURL(serverUrl);
  134. const { ok } = await w.webContents.executeJavaScript(`
  135. navigator.mediaDevices.getDisplayMedia({
  136. video: true,
  137. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  138. `, true);
  139. expect(requestHandlerCalled).to.be.true();
  140. expect(ok).to.be.false();
  141. expect(callbackError?.message).to.equal('Video was requested, but no video stream was provided');
  142. });
  143. it('does not crash when providing only an audio stream for an audio+video request', async () => {
  144. const ses = session.fromPartition('' + Math.random());
  145. let requestHandlerCalled = false;
  146. let callbackError: any;
  147. ses.setDisplayMediaRequestHandler((request, callback) => {
  148. requestHandlerCalled = true;
  149. try {
  150. callback({
  151. audio: 'loopback'
  152. });
  153. } catch (e) {
  154. callbackError = e;
  155. }
  156. });
  157. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  158. await w.loadURL(serverUrl);
  159. const { ok } = await w.webContents.executeJavaScript(`
  160. navigator.mediaDevices.getDisplayMedia({
  161. video: true,
  162. audio: true,
  163. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  164. `, true);
  165. expect(requestHandlerCalled).to.be.true();
  166. expect(ok).to.be.false();
  167. expect(callbackError?.message).to.equal('Video was requested, but no video stream was provided');
  168. });
  169. it('does not crash when providing a non-loopback audio stream', async () => {
  170. const ses = session.fromPartition('' + Math.random());
  171. let requestHandlerCalled = false;
  172. ses.setDisplayMediaRequestHandler((request, callback) => {
  173. requestHandlerCalled = true;
  174. callback({
  175. video: w.webContents.mainFrame,
  176. audio: 'default' as any
  177. });
  178. });
  179. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  180. await w.loadURL(serverUrl);
  181. const { ok } = await w.webContents.executeJavaScript(`
  182. navigator.mediaDevices.getDisplayMedia({
  183. video: true,
  184. audio: true,
  185. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  186. `, true);
  187. expect(requestHandlerCalled).to.be.true();
  188. expect(ok).to.be.true();
  189. });
  190. it('does not crash when providing no streams', async () => {
  191. const ses = session.fromPartition('' + Math.random());
  192. let requestHandlerCalled = false;
  193. let callbackError: any;
  194. ses.setDisplayMediaRequestHandler((request, callback) => {
  195. requestHandlerCalled = true;
  196. try {
  197. callback({});
  198. } catch (e) {
  199. callbackError = e;
  200. }
  201. });
  202. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  203. await w.loadURL(serverUrl);
  204. const { ok } = await w.webContents.executeJavaScript(`
  205. navigator.mediaDevices.getDisplayMedia({
  206. video: true,
  207. audio: true,
  208. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  209. `, true);
  210. expect(requestHandlerCalled).to.be.true();
  211. expect(ok).to.be.false();
  212. expect(callbackError.message).to.equal('Video was requested, but no video stream was provided');
  213. });
  214. it('does not crash when using a bogus web-contents-media-stream:// ID', async () => {
  215. const ses = session.fromPartition('' + Math.random());
  216. let requestHandlerCalled = false;
  217. ses.setDisplayMediaRequestHandler((request, callback) => {
  218. requestHandlerCalled = true;
  219. callback({
  220. video: { id: 'web-contents-media-stream://9999:9999', name: 'whatever' }
  221. });
  222. });
  223. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  224. await w.loadURL(serverUrl);
  225. const { ok } = await w.webContents.executeJavaScript(`
  226. navigator.mediaDevices.getDisplayMedia({
  227. video: true,
  228. audio: true,
  229. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  230. `, true);
  231. expect(requestHandlerCalled).to.be.true();
  232. // This is a little surprising... apparently chrome will generate a stream
  233. // for this non-existent web contents?
  234. expect(ok).to.be.true();
  235. });
  236. it('is not called when calling getUserMedia', async () => {
  237. const ses = session.fromPartition('' + Math.random());
  238. ses.setDisplayMediaRequestHandler(() => {
  239. throw new Error('bad');
  240. });
  241. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  242. await w.loadURL(serverUrl);
  243. const { ok, message } = await w.webContents.executeJavaScript(`
  244. navigator.mediaDevices.getUserMedia({
  245. video: true,
  246. audio: true,
  247. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  248. `);
  249. expect(ok).to.be.true(message);
  250. });
  251. it('works when calling getDisplayMedia with preferCurrentTab', async () => {
  252. const ses = session.fromPartition('' + Math.random());
  253. let requestHandlerCalled = false;
  254. ses.setDisplayMediaRequestHandler((request, callback) => {
  255. requestHandlerCalled = true;
  256. callback({ video: w.webContents.mainFrame });
  257. });
  258. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  259. await w.loadURL(serverUrl);
  260. const { ok, message } = await w.webContents.executeJavaScript(`
  261. navigator.mediaDevices.getDisplayMedia({
  262. preferCurrentTab: true,
  263. video: true,
  264. audio: true,
  265. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  266. `, true);
  267. expect(requestHandlerCalled).to.be.true();
  268. expect(ok).to.be.true(message);
  269. });
  270. it('returns a MediaStream with BrowserCaptureMediaStreamTrack when the current tab is selected', async () => {
  271. const ses = session.fromPartition('' + Math.random());
  272. let requestHandlerCalled = false;
  273. ses.setDisplayMediaRequestHandler((request, callback) => {
  274. requestHandlerCalled = true;
  275. callback({ video: w.webContents.mainFrame });
  276. });
  277. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  278. await w.loadURL(serverUrl);
  279. const { ok, message } = await w.webContents.executeJavaScript(`
  280. navigator.mediaDevices.getDisplayMedia({
  281. preferCurrentTab: true,
  282. video: true,
  283. audio: false,
  284. }).then(stream => {
  285. const [videoTrack] = stream.getVideoTracks();
  286. return { ok: videoTrack instanceof BrowserCaptureMediaStreamTrack, message: null };
  287. }, e => ({ok: false, message: e.message}))
  288. `, true);
  289. expect(requestHandlerCalled).to.be.true();
  290. expect(ok).to.be.true(message);
  291. });
  292. ifit(!(process.platform === 'darwin' && process.arch === 'x64'))('can supply a screen response to preferCurrentTab', async () => {
  293. const ses = session.fromPartition('' + Math.random());
  294. let requestHandlerCalled = false;
  295. ses.setDisplayMediaRequestHandler(async (request, callback) => {
  296. requestHandlerCalled = true;
  297. const sources = await desktopCapturer.getSources({ types: ['screen'] });
  298. callback({ video: sources[0] });
  299. });
  300. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  301. await w.loadURL(serverUrl);
  302. const { ok, message } = await w.webContents.executeJavaScript(`
  303. navigator.mediaDevices.getDisplayMedia({
  304. preferCurrentTab: true,
  305. video: true,
  306. audio: true,
  307. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  308. `, true);
  309. expect(requestHandlerCalled).to.be.true();
  310. expect(ok).to.be.true(message);
  311. });
  312. it('can supply a frame response', async () => {
  313. const ses = session.fromPartition('' + Math.random());
  314. let requestHandlerCalled = false;
  315. ses.setDisplayMediaRequestHandler(async (request, callback) => {
  316. requestHandlerCalled = true;
  317. callback({ video: w.webContents.mainFrame });
  318. });
  319. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  320. await w.loadURL(serverUrl);
  321. const { ok, message } = await w.webContents.executeJavaScript(`
  322. navigator.mediaDevices.getDisplayMedia({
  323. video: true,
  324. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  325. `, true);
  326. expect(requestHandlerCalled).to.be.true();
  327. expect(ok).to.be.true(message);
  328. });
  329. it('is not called when calling legacy getUserMedia', async () => {
  330. const ses = session.fromPartition('' + Math.random());
  331. ses.setDisplayMediaRequestHandler(() => {
  332. throw new Error('bad');
  333. });
  334. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  335. await w.loadURL(serverUrl);
  336. const { ok, message } = await w.webContents.executeJavaScript(`
  337. new Promise((resolve, reject) => navigator.getUserMedia({
  338. video: true,
  339. audio: true,
  340. }, x => resolve({ok: x instanceof MediaStream}), e => reject({ok: false, message: e.message})))
  341. `);
  342. expect(ok).to.be.true(message);
  343. });
  344. it('is not called when calling legacy getUserMedia with desktop capture constraint', async () => {
  345. const ses = session.fromPartition('' + Math.random());
  346. ses.setDisplayMediaRequestHandler(() => {
  347. throw new Error('bad');
  348. });
  349. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  350. await w.loadURL(serverUrl);
  351. const { ok, message } = await w.webContents.executeJavaScript(`
  352. new Promise((resolve, reject) => navigator.getUserMedia({
  353. video: {
  354. mandatory: {
  355. chromeMediaSource: 'desktop'
  356. }
  357. },
  358. }, x => resolve({ok: x instanceof MediaStream}), e => reject({ok: false, message: e.message})))
  359. `);
  360. expect(ok).to.be.true(message);
  361. });
  362. it('works when calling getUserMedia without a media request handler', async () => {
  363. const w = new BrowserWindow({ show: false });
  364. await w.loadURL(serverUrl);
  365. const { ok, message } = await w.webContents.executeJavaScript(`
  366. navigator.mediaDevices.getUserMedia({
  367. video: true,
  368. audio: true,
  369. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  370. `);
  371. expect(ok).to.be.true(message);
  372. });
  373. it('works when calling legacy getUserMedia without a media request handler', async () => {
  374. const w = new BrowserWindow({ show: false });
  375. await w.loadURL(serverUrl);
  376. const { ok, message } = await w.webContents.executeJavaScript(`
  377. new Promise((resolve, reject) => navigator.getUserMedia({
  378. video: true,
  379. audio: true,
  380. }, x => resolve({ok: x instanceof MediaStream}), e => reject({ok: false, message: e.message})))
  381. `);
  382. expect(ok).to.be.true(message);
  383. });
  384. it('can remove a displayMediaRequestHandler', async () => {
  385. const ses = session.fromPartition('' + Math.random());
  386. ses.setDisplayMediaRequestHandler(() => {
  387. throw new Error('bad');
  388. });
  389. ses.setDisplayMediaRequestHandler(null);
  390. const w = new BrowserWindow({ show: false, webPreferences: { session: ses } });
  391. await w.loadURL(serverUrl);
  392. const { ok, message } = await w.webContents.executeJavaScript(`
  393. navigator.mediaDevices.getDisplayMedia({
  394. video: true,
  395. }).then(x => ({ok: x instanceof MediaStream}), e => ({ok: false, message: e.message}))
  396. `, true);
  397. expect(ok).to.be.false();
  398. expect(message).to.equal('Not supported');
  399. });
  400. });