Skip to content
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

feat: screen sharing #595

Merged
merged 11 commits into from
Apr 10, 2024
4 changes: 4 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ module.exports = {
},

rules: {
// import/default is not compatible with SFC Setup .vue files
// It works fine on server because by default in @nextcloud/eslint-config .vue files are not inspected via eslint-plugin-import thus import/extensions doesn't include .vue
// See: https://github.com/import-js/eslint-plugin-import/blob/main/README.md#importextensions
'import/default': 'off',
/**
* ESLint
*/
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
## 👾 Drawbacks

- Currently not supported:
- Screen sharing ([#11](https://github.com/nextcloud/talk-desktop/issues/11))
- Setting User Status ([#26](https://github.com/nextcloud/talk-desktop/issues/26))
- Search ([#30](https://github.com/nextcloud/talk-desktop/issues/30))
- Untrusted certificate on Linux ([#23](https://github.com/nextcloud/talk-desktop/issues/23))
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"release:package": "zx ./scripts/prepare-release-packages.mjs"
},
"dependencies": {
"@mdi/svg": "^7.4.47",
"@nextcloud/axios": "^2.4.0",
"@nextcloud/browser-storage": "^0.3.0",
"@nextcloud/capabilities": "^1.1.0",
Expand Down
33 changes: 31 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@
*/

const path = require('node:path')
const { app, dialog, BrowserWindow, ipcMain } = require('electron')
const { app, dialog, BrowserWindow, ipcMain, desktopCapturer, systemPreferences, shell } = require('electron')
const { setupMenu } = require('./app/app.menu.js')
const { setupReleaseNotificationScheduler } = require('./app/githubReleaseNotification.service.js')
const { enableWebRequestInterceptor, disableWebRequestInterceptor } = require('./app/webRequestInterceptor.js')
const { createAuthenticationWindow } = require('./authentication/authentication.window.js')
const { openLoginWebView } = require('./authentication/login.window.js')
const { createHelpWindow } = require('./help/help.window.js')
const { createUpgradeWindow } = require('./upgrade/upgrade.window.js')
const { getOs, isLinux } = require('./shared/os.utils.js')
const { getOs, isLinux, isMac, isWayland } = require('./shared/os.utils.js')
const { createTalkWindow } = require('./talk/talk.window.js')
const { createWelcomeWindow } = require('./welcome/welcome.window.js')
const { installVueDevtools } = require('./install-vue-devtools.js')
Expand Down Expand Up @@ -82,6 +82,35 @@ ipcMain.on('app:relaunch', () => {
app.relaunch()
app.exit(0)
})
ipcMain.handle('app:getDesktopCapturerSources', async () => {
// macOS 10.15 Catalina or higher requires consent for screen access
if (isMac() && systemPreferences.getMediaAccessStatus('screen') !== 'granted') {
// Open System Preferences to allow screen recording
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture')
// We cannot detect that the user has granted access, so return no sources
// The user will have to try again after granting access
return null
}

// We cannot show live previews on Wayland, so we show thumbnails
const thumbnailWidth = isWayland() ? 320 : 0

const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
fetchWindowIcons: true,
thumbnailSize: {
width: thumbnailWidth,
height: thumbnailWidth * 9 / 16,
},
})

return sources.map((source) => ({
id: source.id,
name: source.name,
icon: source.appIcon && !source.appIcon.isEmpty() ? source.appIcon.toDataURL() : null,
thumbnail: source.thumbnail && !source.thumbnail.isEmpty() ? source.thumbnail.toDataURL() : null,
}))
})

app.whenReady().then(async () => {
try {
Expand Down
6 changes: 6 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ const TALK_DESKTOP = {
* @return {Promise<void>}
*/
setBadgeCount: (count) => ipcRenderer.invoke('app:setBadgeCount', count),
/**
* Get available desktop capture sources: screens and windows
*
* @return {Promise<{ id: string, name: string, icon?: string }[]|null>}
*/
getDesktopCapturerSources: () => ipcRenderer.invoke('app:getDesktopCapturerSources'),
/**
* Relaunch the application
*/
Expand Down
12 changes: 12 additions & 0 deletions src/shared/os.utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,21 @@ function isWindows() {
return os.type() === 'Windows_NT'
}

/**
* Is it Linux with Wayland window communication protocol?
* @return {boolean}
*/
function isWayland() {
// TODO: is it better than checking for XDG_SESSION_TYPE === 'wayland'?
return !!process.env.WAYLAND_DISPLAY
}

/**
* @typedef OsVersion
* @property {boolean} isLinux - Is Linux?
* @property {boolean} isMac - Is Mac?
* @property {boolean} isWindows - Is Windows?
* @property {boolean} isWayland - Is Linux with Wayland window communication protocol?
* @property {string} version - Full string representation of OS version
*/

Expand All @@ -94,6 +104,7 @@ function getOs() {
isLinux: isLinux(),
isMac: isMac(),
isWindows: isWindows(),
isWayland: isWayland(),
version: getOsVersion(),
}
}
Expand All @@ -104,5 +115,6 @@ module.exports = {
isLinux,
isMac,
isWindows,
isWayland,
getOs,
}
56 changes: 56 additions & 0 deletions src/talk/renderer/AppGetDesktopMediaSource.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!--
- @copyright Copyright (c) 2024 Grigorii Shartsev <me@shgk.me>
-
- @author Grigorii Shartsev <me@shgk.me>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->

<script setup>
import { ref } from 'vue'

import DesktopMediaSourceDialog from './components/DesktopMediaSourceDialog.vue'

const showDialog = ref(false)

let promiseWithResolvers = null

const handlePrompt = (sourceId) => {
promiseWithResolvers.resolve({ sourceId })
promiseWithResolvers = null
showDialog.value = false
}

/**
* Prompt user to select a desktop media source to share and return the selected sourceId or an empty string if canceled
*
* @return {Promise<{ sourceId: string }>} sourceId of the selected mediaSource or an empty string if canceled
*/
function promptDesktopMediaSource() {
if (promiseWithResolvers) {
return promiseWithResolvers.promise
}
showDialog.value = true
promiseWithResolvers = Promise.withResolvers()
return promiseWithResolvers.promise
}

defineExpose({ promptDesktopMediaSource })
</script>

<template>
<DesktopMediaSourceDialog v-if="showDialog" @submit="handlePrompt($event)" @cancel="handlePrompt('')" />
</template>
156 changes: 156 additions & 0 deletions src/talk/renderer/components/DesktopMediaSourceDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<!--
- @copyright Copyright (c) 2024 Grigorii Shartsev <me@shgk.me>
-
- @author Grigorii Shartsev <me@shgk.me>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->

<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

import MdiCancel from '@mdi/svg/svg/cancel.svg?raw'
import MdiMonitorShare from '@mdi/svg/svg/monitor-share.svg?raw'

import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'

import { translate as t } from '@nextcloud/l10n'
import DesktopMediaSourcePreview from './DesktopMediaSourcePreview.vue'

const emit = defineEmits(['submit', 'cancel'])

const RE_REQUEST_SOURCES_TIMEOUT = 1000

// On Wayland getting each stream for the live preview requests user to select the source via system dialog again
// Instead - show static images.
// See: https://github.com/electron/electron/issues/27732
const previewType = window.OS.isWayland ? 'thumbnail' : 'live'

const selectedSourceId = ref(null)
const sources = ref(null)

const handleSubmit = () => emit('submit', selectedSourceId.value)
const handleCancel = () => emit('cancel')

const dialogButtons = computed(() => [
{
label: t('talk_desktop', 'Cancel'),
icon: MdiCancel,
callback: handleCancel,
},
{
label: t('talk_desktop', 'Share screen'),
type: 'primary',
icon: MdiMonitorShare,
disabled: !selectedSourceId.value,
callback: handleSubmit,
},
])

const requestDesktopCapturerSources = async () => {
sources.value = await window.TALK_DESKTOP.getDesktopCapturerSources()

// There is no source. Probably the user hasn't granted the permission.
if (!sources.value) {
emit('cancel')
}

// On Wayland we don't manually provide the source stream. It is covered by Wayland and custom id is not supported
if (!window.OS.isWayland) {
// There is no sourceId for the entire desktop with all the screens and audio in Electron.
// But it is possible to capture it. "entire-desktop:0:0" is a custom sourceId for this specific case.
const hasMultipleScreens = sources.value.filter((source) => source.id.startsWith('screen:')).length > 1
sources.value.unshift({
id: 'entire-desktop:0:0',
name: hasMultipleScreens ? t('talk_desktop', 'All screens with audio') : t('talk_desktop', 'Entire screen with audio'),
})
}

// On Wayland there might be no name from the desktopCapturer
if (window.OS.isWayland) {
for (const source of sources.value) {
source.name ||= t('talk_desktop', 'Selected screen or window')
}
}
}

const handleVideoSuspend = (source) => {
sources.value.splice(sources.value.indexOf(source), 1)
if (selectedSourceId.value === source.id) {
selectedSourceId.value = null
}
}

let reRequestTimeout

const scheduleRequestDesktopCaprutererSources = () => {
reRequestTimeout = setTimeout(async () => {
await requestDesktopCapturerSources()
scheduleRequestDesktopCaprutererSources()
}, RE_REQUEST_SOURCES_TIMEOUT)
}

onMounted(async () => {
await requestDesktopCapturerSources()

// Preselect the first media source if any
if (!selectedSourceId.value) {
selectedSourceId.value = sources.value[0]?.id
}

if (previewType === 'live') {
scheduleRequestDesktopCaprutererSources()
}
})

onBeforeUnmount(() => {
if (reRequestTimeout) {
clearTimeout(reRequestTimeout)
}
})
</script>

<template>
<NcDialog :name="t('talk_desktop', 'Choose what to share')"
size="normal"
:buttons="dialogButtons"
@update:open="handleCancel">
<div v-if="sources" class="capture-source-grid">
<DesktopMediaSourcePreview v-for="source in sources"
:key="source.id"
:source="source"
:selected="selectedSourceId === source.id"
@select="selectedSourceId = source.id"
@suspend="handleVideoSuspend(source)" />
</div>
<NcEmptyContent v-else :name="t('talk_desktop', 'Loading …')">
<template #icon>
<NcLoadingIcon />
</template>
</NcEmptyContent>
</NcDialog>
</template>

<style scoped lang="scss">
.capture-source-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-gap: calc(var(--default-grid-baseline) * 2);
width: 100%;
}
</style>
Loading
Loading