mirror of
https://github.com/Llewellynvdm/nativefier.git
synced 2024-12-22 10:08:55 +00:00
Add getDisplayMedia and PipeWire support (#1477)
I'm picking up @RickStanley's abandoned PR #1321 again to add screensharing support (fixes #927), with the following additional changes: - In newer Electron versions, `desktopCapturer.getSources` must be called from the main process, so I solved this with an IPC call. - Importing from `./helpers/helpers` in 'preload.ts' does not work, as was mentioned by @DimICE in https://github.com/nativefier/nativefier/pull/1321#issuecomment-1001518035. I'm not very familiar with TypeScript or Electron, so not sure why that is and how it could be solved - for now I just copied the referenced `isWayland` function to `preload.ts`. - Add a screensharing test to the manual test script, as requested by @ronjouch in https://github.com/nativefier/nativefier/pull/1321#issuecomment-1006725818 As far as I understood from the discussion in #1321, the last point was basically the only thing that was missing to get this merged, correct? --------- Co-authored-by: Rick Stanley <rick-stanley@outlook.com> Co-authored-by: Rick Stanley <rick.stanley@lambda3.com.br> Co-authored-by: Ronan Jouchet <ronan@jouchet.fr>
This commit is contained in:
parent
8d3396acc3
commit
79009e87cd
20
.github/manual-test
vendored
20
.github/manual-test
vendored
@ -101,3 +101,23 @@ printf '\n***** SMOKE TEST 3: Test checklist *****
|
|||||||
|
|
||||||
launch_app "$tmp_dir" "$name"
|
launch_app "$tmp_dir" "$name"
|
||||||
request_feedback "$tmp_dir"
|
request_feedback "$tmp_dir"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
printf '\n***** SMOKE TEST 4: Setting up test and building app... *****\n'
|
||||||
|
tmp_dir=$(mktemp -d -t nativefier-manual-test-start-in-tray-XXXXX)
|
||||||
|
name='nativefier-smoke-test-4'
|
||||||
|
node ./lib/cli.js 'https://meet.jit.si/nativefier-test' \
|
||||||
|
--name "$name" \
|
||||||
|
"$tmp_dir"
|
||||||
|
|
||||||
|
printf '\n***** SMOKE TEST 4: Test checklist *****
|
||||||
|
- Join the Jitsi meeting and try to share your screen
|
||||||
|
(third button from the left in the bottom bar)
|
||||||
|
- An overlay should appear where you can select a screen/window to share
|
||||||
|
- After selecting a screen, a thumbnail of the shared screen should appear on
|
||||||
|
the top right
|
||||||
|
- Console: no Electron runtime deprecation warnings/error logged'
|
||||||
|
|
||||||
|
launch_app "$tmp_dir" "$name"
|
||||||
|
request_feedback "$tmp_dir"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { ipcMain, BrowserWindow, Event } from 'electron';
|
import { ipcMain, desktopCapturer, BrowserWindow, Event } from 'electron';
|
||||||
import windowStateKeeper from 'electron-window-state';
|
import windowStateKeeper from 'electron-window-state';
|
||||||
|
|
||||||
import { initContextMenu } from './contextMenu';
|
import { initContextMenu } from './contextMenu';
|
||||||
@ -155,6 +155,7 @@ export async function createMainWindow(
|
|||||||
});
|
});
|
||||||
|
|
||||||
setupSessionInteraction(options, mainWindow);
|
setupSessionInteraction(options, mainWindow);
|
||||||
|
setupSessionPermissionHandler(mainWindow);
|
||||||
|
|
||||||
if (options.clearCache) {
|
if (options.clearCache) {
|
||||||
await clearCache(mainWindow);
|
await clearCache(mainWindow);
|
||||||
@ -230,6 +231,22 @@ function setupCounter(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupSessionPermissionHandler(window: BrowserWindow): void {
|
||||||
|
window.webContents.session.setPermissionCheckHandler(() => {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
window.webContents.session.setPermissionRequestHandler(
|
||||||
|
(_webContents, _permission, callback) => {
|
||||||
|
callback(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ipcMain.handle('desktop-capturer-get-sources', () => {
|
||||||
|
return desktopCapturer.getSources({
|
||||||
|
types: ['screen', 'window'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setupNotificationBadge(
|
function setupNotificationBadge(
|
||||||
options: OutputOptions,
|
options: OutputOptions,
|
||||||
window: BrowserWindow,
|
window: BrowserWindow,
|
||||||
|
@ -284,6 +284,16 @@ export function openExternal(
|
|||||||
return shell.openExternal(url, options);
|
return shell.openExternal(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy-pastaed as unable to get imports to work in preload.
|
||||||
|
// If modifying, update also app/src/preload.ts
|
||||||
|
export function isWayland(): boolean {
|
||||||
|
return (
|
||||||
|
isLinux() &&
|
||||||
|
(Boolean(process.env.WAYLAND_DISPLAY) ||
|
||||||
|
process.env.XDG_SESSION_TYPE === 'wayland')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function removeUserAgentSpecifics(
|
export function removeUserAgentSpecifics(
|
||||||
userAgentFallback: string,
|
userAgentFallback: string,
|
||||||
appName: string,
|
appName: string,
|
||||||
|
@ -20,7 +20,12 @@ import {
|
|||||||
APP_ARGS_FILE_PATH,
|
APP_ARGS_FILE_PATH,
|
||||||
} from './components/mainWindow';
|
} from './components/mainWindow';
|
||||||
import { createTrayIcon } from './components/trayIcon';
|
import { createTrayIcon } from './components/trayIcon';
|
||||||
import { isOSX, isWindows, removeUserAgentSpecifics } from './helpers/helpers';
|
import {
|
||||||
|
isOSX,
|
||||||
|
isWayland,
|
||||||
|
isWindows,
|
||||||
|
removeUserAgentSpecifics,
|
||||||
|
} from './helpers/helpers';
|
||||||
import { inferFlashPath } from './helpers/inferFlash';
|
import { inferFlashPath } from './helpers/inferFlash';
|
||||||
import * as log from './helpers/loggingHelper';
|
import * as log from './helpers/loggingHelper';
|
||||||
import {
|
import {
|
||||||
@ -176,6 +181,10 @@ if (appArgs.basicAuthPassword) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWayland()) {
|
||||||
|
app.commandLine.appendSwitch('enable-features', 'WebRTCPipeWireCapturer');
|
||||||
|
}
|
||||||
|
|
||||||
if (appArgs.lang) {
|
if (appArgs.lang) {
|
||||||
const langParts = appArgs.lang.split(',');
|
const langParts = appArgs.lang.split(',');
|
||||||
// Convert locales to languages, because for some reason locales don't work. Stupid Chromium
|
// Convert locales to languages, because for some reason locales don't work. Stupid Chromium
|
||||||
|
@ -8,6 +8,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
@ -61,6 +62,229 @@ function setNotificationCallback(
|
|||||||
window.Notification = newNotify;
|
window.Notification = newNotify;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getDisplayMedia(
|
||||||
|
sourceId: number | string,
|
||||||
|
): Promise<MediaStream> {
|
||||||
|
type OriginalVideoPropertyType = boolean | MediaTrackConstraints | undefined;
|
||||||
|
// Electron supports an outdated specification for mediaDevices,
|
||||||
|
// see https://www.electronjs.org/docs/latest/api/desktop-capturer/
|
||||||
|
const stream = await window.navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: sourceId,
|
||||||
|
},
|
||||||
|
} as unknown as OriginalVideoPropertyType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupScreenSharePickerStyles(id: string): void {
|
||||||
|
const screenShareStyles = document.createElement('style');
|
||||||
|
screenShareStyles.id = id;
|
||||||
|
screenShareStyles.innerHTML = `
|
||||||
|
.desktop-capturer-selection {
|
||||||
|
--overlay-color: hsla(0, 0%, 11.8%, 0.75);
|
||||||
|
--highlight-color: highlight;
|
||||||
|
--text-content-color: #fff;
|
||||||
|
--selection-button-color: hsl(180, 1.3%, 14.7%);
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--overlay-color);
|
||||||
|
color: var(--text-content-color);
|
||||||
|
z-index: 10000000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__close {
|
||||||
|
-moz-appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
padding: 1rem;
|
||||||
|
color: inherit;
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__scroller {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__list {
|
||||||
|
max-width: calc(100% - 100px);
|
||||||
|
margin: 50px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
list-style: none;
|
||||||
|
overflow: hidden;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__item {
|
||||||
|
display: flex;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 145px;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--selection-button-color);
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__btn:hover,
|
||||||
|
.desktop-capturer-selection__btn:focus {
|
||||||
|
background: var(--highlight-color);
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 81px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.desktop-capturer-selection__name {
|
||||||
|
margin: 6px 0 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.desktop-capturer-selection {
|
||||||
|
--overlay-color: hsla(0, 0%, 90.2%, 0.75);
|
||||||
|
--text-content-color: hsl(0, 0%, 12.9%);
|
||||||
|
--selection-button-color: hsl(180, 1.3%, 85.3%);
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
document.head.appendChild(screenShareStyles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupScreenSharePickerElement(
|
||||||
|
id: string,
|
||||||
|
sources: Electron.DesktopCapturerSource[],
|
||||||
|
): void {
|
||||||
|
const selectionElem = document.createElement('div');
|
||||||
|
selectionElem.classList.add('desktop-capturer-selection');
|
||||||
|
selectionElem.id = id;
|
||||||
|
selectionElem.innerHTML = `
|
||||||
|
<button class="desktop-capturer-selection__close" id="${id}-close" aria-label="Close screen share picker" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="32" height="32">
|
||||||
|
<path fill="currentColor" d="m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="desktop-capturer-selection__scroller">
|
||||||
|
<ul class="desktop-capturer-selection__list">
|
||||||
|
${sources
|
||||||
|
.map(
|
||||||
|
({ id, name, thumbnail }) => `
|
||||||
|
<li class="desktop-capturer-selection__item">
|
||||||
|
<button class="desktop-capturer-selection__btn" data-id="${id}" title="${name}">
|
||||||
|
<img class="desktop-capturer-selection__thumbnail" src="${thumbnail.toDataURL()}" />
|
||||||
|
<span class="desktop-capturer-selection__name">${name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(selectionElem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupScreenSharePicker(
|
||||||
|
resolve: (value: MediaStream | PromiseLike<MediaStream>) => void,
|
||||||
|
reject: (reason?: unknown) => void,
|
||||||
|
sources: Electron.DesktopCapturerSource[],
|
||||||
|
): void {
|
||||||
|
const baseElementsId = 'native-screen-share-picker';
|
||||||
|
const pickerStylesElementId = baseElementsId + '-styles';
|
||||||
|
|
||||||
|
setupScreenSharePickerElement(baseElementsId, sources);
|
||||||
|
setupScreenSharePickerStyles(pickerStylesElementId);
|
||||||
|
|
||||||
|
const clearElements = (): void => {
|
||||||
|
document.getElementById(pickerStylesElementId)?.remove();
|
||||||
|
document.getElementById(baseElementsId)?.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById(`${baseElementsId}-close`)
|
||||||
|
?.addEventListener('click', () => {
|
||||||
|
clearElements();
|
||||||
|
reject('Screen share was cancelled by the user.');
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll('.desktop-capturer-selection__btn')
|
||||||
|
.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const id = button.getAttribute('data-id');
|
||||||
|
if (!id) {
|
||||||
|
log.error("Couldn't find `data-id` of element");
|
||||||
|
clearElements();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = sources.find((source) => source.id === id);
|
||||||
|
if (!source) {
|
||||||
|
log.error(`Source with id "${id}" does not exist`);
|
||||||
|
clearElements();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayMedia(source.id)
|
||||||
|
.then((stream) => {
|
||||||
|
resolve(stream);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log.error('Error selecting desktop capture source:', err);
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
clearElements();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDisplayMediaPromise(): void {
|
||||||
|
// Since no implementation for `getDisplayMedia` exists in Electron we write our own.
|
||||||
|
window.navigator.mediaDevices.getDisplayMedia = (): Promise<MediaStream> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sources = ipcRenderer.invoke(
|
||||||
|
'desktop-capturer-get-sources',
|
||||||
|
) as Promise<Electron.DesktopCapturerSource[]>;
|
||||||
|
sources
|
||||||
|
.then(async (sources) => {
|
||||||
|
if (isWayland()) {
|
||||||
|
// No documentation is provided wether the first element is always PipeWire-picked or not
|
||||||
|
// i.e. maybe it's not deterministic, we are only taking a guess here.
|
||||||
|
const stream = await getDisplayMedia(sources[0].id);
|
||||||
|
resolve(stream);
|
||||||
|
} else {
|
||||||
|
setupScreenSharePicker(resolve, reject, sources);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function injectScripts(): void {
|
function injectScripts(): void {
|
||||||
const needToInject = fs.existsSync(INJECT_DIR);
|
const needToInject = fs.existsSync(INJECT_DIR);
|
||||||
if (!needToInject) {
|
if (!needToInject) {
|
||||||
@ -95,13 +319,28 @@ function notifyNotificationClick(): void {
|
|||||||
|
|
||||||
// @ts-expect-error TypeScript thinks these are incompatible but they aren't
|
// @ts-expect-error TypeScript thinks these are incompatible but they aren't
|
||||||
setNotificationCallback(notifyNotificationCreate, notifyNotificationClick);
|
setNotificationCallback(notifyNotificationCreate, notifyNotificationClick);
|
||||||
|
setDisplayMediaPromise();
|
||||||
|
|
||||||
ipcRenderer.on('params', (event, message: string) => {
|
ipcRenderer.on('params', (event, message: string) => {
|
||||||
log.debug('ipcRenderer.params', { event, message });
|
log.debug('ipcRenderer.params', { event, message });
|
||||||
const appArgs = JSON.parse(message) as OutputOptions;
|
const appArgs: unknown = JSON.parse(message) as OutputOptions;
|
||||||
log.info('nativefier.json', appArgs);
|
log.info('nativefier.json', appArgs);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcRenderer.on('debug', (event, message: string) => {
|
ipcRenderer.on('debug', (event, message: string) => {
|
||||||
log.debug('ipcRenderer.debug', { event, message });
|
log.debug('ipcRenderer.debug', { event, message });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Copy-pastaed as unable to get imports to work in preload.
|
||||||
|
// If modifying, update also app/src/helpers/helpers.ts
|
||||||
|
function isWayland(): boolean {
|
||||||
|
return (
|
||||||
|
isLinux() &&
|
||||||
|
(Boolean(process.env.WAYLAND_DISPLAY) ||
|
||||||
|
process.env.XDG_SESSION_TYPE === 'wayland')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinux(): boolean {
|
||||||
|
return os.platform() === 'linux';
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user