Repository navigation
feat(screen-share): let users choose which screen or window to share #238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| import { | ||
| BrowserWindow, | ||
| DesktopCapturerSource, | ||
| desktopCapturer, | ||
| screen, | ||
| session, | ||
| } from 'electron' | ||
| import { IPC_EVENTS } from '@shared/constants' | ||
| import { ScreenShareSource } from '@shared/types' | ||
| import { Log } from '@shared/utils/logger' | ||
| import { forceWindowFocus } from '@/lib/windowsFocus' | ||
| import { SCREEN_SHARE_PICKER_SIZE, ScreenSharePickerWindow } from '../windows' | ||
| import { PhoneIslandController } from './PhoneIslandController' | ||
|
|
||
| type SettleRequest = (source?: DesktopCapturerSource) => void | ||
|
|
||
| const PICKER_LOAD_TIMEOUT_MS = 30000 | ||
|
|
||
| export class ScreenSharePickerController { | ||
| static instance: ScreenSharePickerController | ||
| private window: ScreenSharePickerWindow | undefined | ||
| private settlePending: SettleRequest | undefined | ||
|
|
||
| constructor() { | ||
| ScreenSharePickerController.instance = this | ||
| session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => | ||
| this.handleRequest(callback), | ||
| ) | ||
| } | ||
|
|
||
| cancel() { | ||
| this.settlePending?.() | ||
| } | ||
|
|
||
| private async handleRequest(callback: (streams: Electron.Streams) => void) { | ||
| if (this.settlePending) { | ||
| Log.warning('SCREEN SHARE request rejected: picker already open') | ||
| callback(null as unknown as Electron.Streams) | ||
| return | ||
| } | ||
|
|
||
| let settled = false | ||
| const restorePhoneIsland = this.lowerPhoneIslandForSystemPicker() | ||
| const settle: SettleRequest = (source) => { | ||
| if (settled) return | ||
| settled = true | ||
| this.settlePending = undefined | ||
| this.destroyWindow() | ||
| restorePhoneIsland() | ||
| if (source) { | ||
| Log.info('SCREEN SHARE source selected', source.id, source.name) | ||
| callback({ video: { id: source.id, name: source.name } }) | ||
| } else { | ||
| Log.info('SCREEN SHARE request cancelled') | ||
| callback(null as unknown as Electron.Streams) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cancel still leaves the Janus room/publisher open in phone-island.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the same root cause discussed in the thread above: the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tracked in NethServer/dev#8237 and fixed in nethesis/phone-island#292 (c5f4cfc): when the publisher |
||
| } | ||
| } | ||
| this.settlePending = settle | ||
|
|
||
| try { | ||
| const sources = await this.getSources() | ||
| if (settled) return | ||
| if (sources.length <= 1) { | ||
| settle(sources[0]) | ||
| return | ||
| } | ||
| this.openPicker(sources, settle) | ||
| } catch (e) { | ||
| Log.error('SCREEN SHARE getSources error', e) | ||
| settle() | ||
| } | ||
| } | ||
|
|
||
| private lowerPhoneIslandForSystemPicker(): () => void { | ||
| const isWayland = | ||
| process.platform === 'linux' && | ||
| (process.env['XDG_SESSION_TYPE'] === 'wayland' || | ||
| !!process.env['WAYLAND_DISPLAY']) | ||
| try { | ||
| const window = PhoneIslandController.instance?.window?.getWindow() | ||
| if ( | ||
| !isWayland || | ||
| !window || | ||
| window.isDestroyed() || | ||
| !window.isAlwaysOnTop() | ||
| ) { | ||
| return () => {} | ||
| } | ||
| window.setAlwaysOnTop(false) | ||
| return () => { | ||
| try { | ||
| if (!window.isDestroyed()) { | ||
| window.setAlwaysOnTop(true, 'screen-saver') | ||
| } | ||
| } catch (e) { | ||
| Log.warning('SCREEN SHARE phone island restore error', e) | ||
| } | ||
| } | ||
| } catch (e) { | ||
| Log.warning('SCREEN SHARE phone island lowering error', e) | ||
| return () => {} | ||
| } | ||
| } | ||
|
|
||
| private async getSources(): Promise<DesktopCapturerSource[]> { | ||
| const ownMediaSourceIds = new Set( | ||
| BrowserWindow.getAllWindows() | ||
| .filter((w) => !w.isDestroyed()) | ||
| .map((w) => w.getMediaSourceId()), | ||
| ) | ||
| const sources = await desktopCapturer.getSources({ | ||
| types: ['screen', 'window'], | ||
| thumbnailSize: { width: 320, height: 180 }, | ||
| fetchWindowIcons: true, | ||
| }) | ||
| return sources.filter((s) => !ownMediaSourceIds.has(s.id)) | ||
| } | ||
|
|
||
| private openPicker(sources: DesktopCapturerSource[], settle: SettleRequest) { | ||
| const pickerWindow = new ScreenSharePickerWindow() | ||
| this.window = pickerWindow | ||
| const window = pickerWindow.getWindow() | ||
| if (!window) { | ||
| settle() | ||
| return | ||
| } | ||
|
|
||
| const serializedSources: ScreenShareSource[] = sources.map((s) => ({ | ||
| id: s.id, | ||
| name: s.name, | ||
| type: s.id.startsWith('screen:') ? 'screen' : 'window', | ||
| thumbnail: s.thumbnail.toDataURL(), | ||
| appIcon: | ||
| s.appIcon && !s.appIcon.isEmpty() ? s.appIcon.toDataURL() : undefined, | ||
| })) | ||
|
|
||
| const loadTimeout = setTimeout(() => { | ||
| Log.warning('SCREEN SHARE picker did not load') | ||
| settle() | ||
| }, PICKER_LOAD_TIMEOUT_MS) | ||
|
|
||
| window.on('closed', () => { | ||
| clearTimeout(loadTimeout) | ||
| settle() | ||
| }) | ||
| window.webContents.on('render-process-gone', () => settle()) | ||
| window.webContents.on('did-fail-load', () => settle()) | ||
|
|
||
| pickerWindow.addListener(IPC_EVENTS.SCREEN_SHARE_PICKER_SOURCES, () => { | ||
| clearTimeout(loadTimeout) | ||
| pickerWindow.emit( | ||
| IPC_EVENTS.SCREEN_SHARE_PICKER_SOURCES, | ||
| serializedSources, | ||
| ) | ||
| this.showCentered(window) | ||
|
tonyco97 marked this conversation as resolved.
|
||
| }) | ||
|
|
||
| pickerWindow.addListener( | ||
| IPC_EVENTS.SCREEN_SHARE_PICKER_SELECT, | ||
| (_event, sourceId: string | null) => { | ||
| settle(sources.find((s) => s.id === sourceId)) | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| private showCentered(window: BrowserWindow) { | ||
| try { | ||
| const phoneIslandBounds = PhoneIslandController.instance?.window | ||
| ?.getWindow() | ||
| ?.getBounds() | ||
| const display = | ||
| phoneIslandBounds && | ||
| phoneIslandBounds.width > 1 && | ||
| phoneIslandBounds.height > 1 | ||
| ? screen.getDisplayMatching(phoneIslandBounds) | ||
| : screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) | ||
| const { x, y, width, height } = display.workArea | ||
| const { width: w, height: h } = SCREEN_SHARE_PICKER_SIZE | ||
| window.setBounds({ | ||
| x: x + Math.round((width - w) / 2), | ||
| y: y + Math.round((height - h) / 2), | ||
| width: w, | ||
| height: h, | ||
| }) | ||
| } catch (e) { | ||
| Log.warning('SCREEN SHARE picker positioning error', e) | ||
| } | ||
| if (process.platform === 'darwin') { | ||
| window.setVisibleOnAllWorkspaces(true, { | ||
| visibleOnFullScreen: true, | ||
| skipTransformProcessType: true, | ||
| }) | ||
| } | ||
| window.show() | ||
| window.setAlwaysOnTop(true, 'screen-saver') | ||
| setTimeout(() => { | ||
| if (window.isDestroyed()) return | ||
| forceWindowFocus(window) | ||
| window.focus() | ||
| window.webContents.focus() | ||
| }, 50) | ||
| } | ||
|
|
||
| private destroyWindow() { | ||
| const window = this.window?.getWindow() | ||
| this.window = undefined | ||
| if (window && !window.isDestroyed()) { | ||
| window.destroy() | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { PAGES } from '@shared/types' | ||
| import { BaseWindow } from './BaseWindow' | ||
|
|
||
| export const SCREEN_SHARE_PICKER_SIZE = { width: 720, height: 520 } | ||
|
|
||
| export class ScreenSharePickerWindow extends BaseWindow { | ||
| constructor() { | ||
| super(PAGES.SCREENSHAREPICKER, { | ||
| width: SCREEN_SHARE_PICKER_SIZE.width, | ||
| height: SCREEN_SHARE_PICKER_SIZE.height, | ||
| show: false, | ||
| focusable: true, | ||
| fullscreenable: false, | ||
| autoHideMenuBar: true, | ||
| closable: true, | ||
| alwaysOnTop: true, | ||
| minimizable: false, | ||
| maximizable: false, | ||
| movable: true, | ||
| resizable: false, | ||
| skipTaskbar: true, | ||
| roundedCorners: true, | ||
| parent: undefined, | ||
| transparent: true, | ||
| hiddenInMissionControl: true, | ||
| hasShadow: false, | ||
| center: true, | ||
| fullscreen: false, | ||
| enableLargerThanScreen: false, | ||
| frame: false, | ||
| thickFrame: false, | ||
| trafficLightPosition: { x: 0, y: 0 }, | ||
| webPreferences: { | ||
| nodeIntegration: true, | ||
| }, | ||
| }) | ||
|
|
||
| this.addOnBuildListener(() => { | ||
| const window = this.getWindow() | ||
| if (window) { | ||
| window.setAlwaysOnTop(true, 'screen-saver') | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
callback(null)surfaces as NotReadableError; phone-island doesn't clean up the Janus room/handle on that path.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed, but the root cause is in phone-island and predates this PR. The
createOffererror callback inVideoViewonly resets the store and never callsplugin.detach(), so the videoroom handle and the created room leak on anygetDisplayMediafailure, including Cancel in the browser picker on CTI web (the rejection type, NotReadableError vs NotAllowedError, makes no difference there). This PR turns Cancel into a regular path in NethLink, so it shows up more often. We will open a dedicated issue to fix it in phone-island (release the handle on that error path) and then bump phone-island here. Working around it from NethLink withphone-island-screen-share-stopis not viable:stopScreenShare()sendsscreenSharingStopto the remote user, whose island would switch to the video view for a share that never started.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tracked in NethServer/dev#8237 and fixed in nethesis/phone-island#292 (c5f4cfc): when the publisher
createOfferfails (picker cancelled or capture error), phone-island now destroys the room it created and detaches the videoroom handle. We will bump phone-island in NethLink once it is released; leaving this thread open until then.