Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions public/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,13 @@
"Placeholder": "Search contact or dial number...",
"Call": "Call"
},
"ScreenShare": {
"Title": "Choose what to share",
"Entire screen": "Entire screen",
"Screen": "Screen {{number}}",
"Window": "Window",
"Share": "Share"
},
"Errors": {
"browser_permissions": "Browser permissions error",
"user_permissions": "Media permissions error",
Expand Down
7 changes: 7 additions & 0 deletions public/locales/it/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,13 @@
"Placeholder": "Cerca contatto o componi numero...",
"Call": "Chiama"
},
"ScreenShare": {
"Title": "Scegli cosa condividere",
"Entire screen": "Schermo intero",
"Screen": "Schermo {{number}}",
"Window": "Finestra",
"Share": "Condividi"
},
"Errors": {
"browser_permissions": "Errore nei permessi del browser",
"user_permissions": "Errore nei permessi dei media",
Expand Down
211 changes: 211 additions & 0 deletions src/main/classes/controllers/ScreenSharePickerController.ts
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)

Copy link
Copy Markdown
Collaborator

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.

Copy link
Copy Markdown
Collaborator Author

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 createOffer error callback in VideoView only resets the store and never calls plugin.detach(), so the videoroom handle and the created room leak on any getDisplayMedia failure, 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 with phone-island-screen-share-stop is not viable: stopScreenShare() sends screenSharingStop to the remote user, whose island would switch to the video view for a share that never started.

Copy link
Copy Markdown
Collaborator Author

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 createOffer fails (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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancel still leaves the Janus room/publisher open in phone-island.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same root cause discussed in the thread above: the createOffer error callback in phone-island VideoView resets the store but never releases the videoroom handle and the room. It predates this PR and also happens on CTI web when the user cancels the browser picker, so it cannot be fixed from NethLink without side effects (phone-island-screen-share-stop would send screenSharingStop to the remote user). We will track it in a dedicated phone-island issue and bump phone-island here once it is released; we would prefer not to block this PR on it.

Copy link
Copy Markdown
Collaborator Author

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 createOffer fails (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.

}
}
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)
Comment thread
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()
}
}
}
45 changes: 45 additions & 0 deletions src/main/classes/windows/ScreenSharePickerWindow.ts
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')
}
})
}
}
1 change: 1 addition & 0 deletions src/main/classes/windows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './NethLinkWindow'
export * from './PhoneIslandWindow'
export * from './DevToolsWindow'
export * from './CommandBarWindow'
export * from './ScreenSharePickerWindow'
15 changes: 0 additions & 15 deletions src/main/lib/ipcEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
net,
screen,
shell,
desktopCapturer,
globalShortcut,
clipboard,
Notification,
Expand Down Expand Up @@ -732,20 +731,6 @@ export function registerIpcEvents() {
Log.error('EXIT FULLSCREEN error ', e)
}
})
ipcMain.on(IPC_EVENTS.SCREEN_SHARE_INIT, () => {
desktopCapturer
.getSources({ types: ['screen'] }) // allow only entire screen sharing
.then((sources) => {
PhoneIslandController.instance?.window?.emit(
IPC_EVENTS.SCREEN_SHARE_SOURCES,
sources,
)
})
.catch((e) => {
Log.error('ENTER SCREEN_SHARE_INIT error ', e)
})
})

ipcMain.on(IPC_EVENTS.URL_OPEN, (_, data) => {
try {
PhoneIslandController.instance.window.emit(IPC_EVENTS.URL_OPEN, data)
Expand Down
5 changes: 5 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { AccountController } from './classes/controllers'
import { PhoneIslandController } from './classes/controllers/PhoneIslandController'
import { CommandBarController } from './classes/controllers/CommandBarController'
import { ScreenSharePickerController } from './classes/controllers/ScreenSharePickerController'
import {
Account,
AuthAppData,
Expand Down Expand Up @@ -130,6 +131,7 @@ function startup() {
Log.warning('Failed to disable Command Bar on logout:', e)
}

ScreenSharePickerController.instance?.cancel()
await PhoneIslandController.instance.logout()
NethLinkController.instance.logout()
AccountController.instance.logout()
Expand Down Expand Up @@ -274,6 +276,7 @@ function attachOnReadyProcess() {
// I create the Tray controller instance - I define to it the function it should execute upon clicking on the icon
new SplashScreenController()
new TrayController()
new ScreenSharePickerController()

// I display the splashscreen when the splashscreen component is correctly loaded.
SplashScreenController.instance.window.addOnBuildListener(() => {
Expand Down Expand Up @@ -546,6 +549,8 @@ function attachOnReadyProcess() {
await CommandBarController.instance.safeQuit()
}

ScreenSharePickerController.instance?.cancel()

Log.info('APP QUIT CORRECTLY')
app.exit()
})
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/public/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,13 @@
"Placeholder": "Search contact or dial number...",
"Call": "Call"
},
"ScreenShare": {
"Title": "Choose what to share",
"Entire screen": "Entire screen",
"Screen": "Screen {{number}}",
"Window": "Window",
"Share": "Share"
},
"Errors": {
"browser_permissions": "Browser permissions error",
"user_permissions": "Media permissions error",
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/public/locales/it/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,13 @@
"Placeholder": "Cerca contatto o componi numero...",
"Call": "Chiama"
},
"ScreenShare": {
"Title": "Scegli cosa condividere",
"Entire screen": "Schermo intero",
"Screen": "Schermo {{number}}",
"Window": "Finestra",
"Share": "Condividi"
},
"Errors": {
"browser_permissions": "Errore nei permessi del browser",
"user_permissions": "Errore nei permessi dei media",
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SplashScreenPage,
NethLinkPage,
CommandBarPage,
ScreenSharePickerPage,
} from '@/pages'
import { loadI18n } from './lib/i18n'
import { Log } from '@shared/utils/logger'
Expand Down Expand Up @@ -143,6 +144,10 @@ const RequestStateComponent = () => {
path: PAGES.COMMANDBAR,
element: <CommandBarPage />,
},
{
path: PAGES.SCREENSHAREPICKER,
element: <ScreenSharePickerPage />,
},
],
},
])
Expand Down
Loading
Loading