diff --git a/README.md b/README.md index cc07303..a48659e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ ## Howto: +# trigger + ### to generate test coverage results ``` diff --git a/app/Routes.tsx b/app/Routes.tsx index 3927751..62fc2bf 100644 --- a/app/Routes.tsx +++ b/app/Routes.tsx @@ -6,22 +6,10 @@ import routes from './constants/routes.json'; import App from './containers/App'; import HomePage from './containers/HomePage'; -// Lazily load routes and code split with webpacck -const LazyCounterPage = React.lazy( - () => import(/* webpackChunkName: "CounterPage" */ './containers/CounterPage') -); - -const CounterPage = (props: Record) => ( - Loading...}> - - -); - export default function Routes() { return ( - diff --git a/app/__snapshots__/menu.spec.ts.snap b/app/__snapshots__/menu.spec.ts.snap new file mode 100644 index 0000000..ac4a45d --- /dev/null +++ b/app/__snapshots__/menu.spec.ts.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`app menu MenyBuilder tests when MenyBuilder created properly when menu from buildDarwinTemplate was created should match a snapshot 1`] = ` +Array [ + Object { + "label": "Deskreen", + "submenu": Array [ + Object { + "label": "About Deskreen", + "selector": "orderFrontStandardAboutPanel:", + }, + Object { + "type": "separator", + }, + Object { + "label": "Services", + "submenu": Array [], + }, + Object { + "type": "separator", + }, + Object { + "accelerator": "Command+H", + "label": "Hide Deskreen", + "selector": "hide:", + }, + Object { + "accelerator": "Command+Shift+H", + "label": "Hide Others", + "selector": "hideOtherApplications:", + }, + Object { + "label": "Show All", + "selector": "unhideAllApplications:", + }, + Object { + "type": "separator", + }, + Object { + "accelerator": "Command+Q", + "click": [Function], + "label": "Quit", + }, + ], + }, + Object { + "label": "Edit", + "submenu": Array [ + Object { + "accelerator": "Command+Z", + "label": "Undo", + "selector": "undo:", + }, + Object { + "accelerator": "Shift+Command+Z", + "label": "Redo", + "selector": "redo:", + }, + Object { + "type": "separator", + }, + Object { + "accelerator": "Command+X", + "label": "Cut", + "selector": "cut:", + }, + Object { + "accelerator": "Command+C", + "label": "Copy", + "selector": "copy:", + }, + Object { + "accelerator": "Command+V", + "label": "Paste", + "selector": "paste:", + }, + Object { + "accelerator": "Command+A", + "label": "Select All", + "selector": "selectAll:", + }, + ], + }, + Object { + "label": "View", + "submenu": Array [ + Object { + "accelerator": "Ctrl+Command+F", + "click": [Function], + "label": "Toggle Full Screen", + }, + ], + }, + Object { + "label": "Window", + "submenu": Array [ + Object { + "accelerator": "Command+M", + "label": "Minimize", + "selector": "performMiniaturize:", + }, + Object { + "accelerator": "Command+W", + "label": "Close", + "selector": "performClose:", + }, + Object { + "type": "separator", + }, + Object { + "label": "Bring All to Front", + "selector": "arrangeInFront:", + }, + ], + }, + Object { + "label": "Help", + "submenu": Array [ + Object { + "click": [Function], + "label": "Learn More", + }, + Object { + "click": [Function], + "label": "Documentation", + }, + Object { + "click": [Function], + "label": "Community Discussions", + }, + Object { + "click": [Function], + "label": "Search Issues", + }, + ], + }, +] +`; diff --git a/app/api/config.ts b/app/api/config.ts index 3cc61ab..40cc0cb 100644 --- a/app/api/config.ts +++ b/app/api/config.ts @@ -1,4 +1,5 @@ /* istanbul ignore file */ + let host; let protocol; let port; diff --git a/app/api/urlGenerator.ts b/app/api/urlGenerator.ts index 4debac7..e39641b 100644 --- a/app/api/urlGenerator.ts +++ b/app/api/urlGenerator.ts @@ -1,4 +1,5 @@ /* istanbul ignore file */ + import config from './config'; export default (resourceName = '') => { diff --git a/app/app.global.css b/app/app.global.css index aa0501a..a2e693c 100644 --- a/app/app.global.css +++ b/app/app.global.css @@ -154,7 +154,9 @@ div.class-allow-device-to-connect-alert > svg > path { color: #a82a2a; - -webkit-animation: blink 0.75s infinite alternate; /* to blink 3 times instead of infinite write just 3 */ + -webkit-animation: blink 0.75s infinite alternate; + + /* to blink 3 times instead of infinite write just 3 */ -moz-animation: blink 0.75s infinite alternate; -ms-animation: blink 0.75s infinite alternate; -o-animation: blink 0.75s infinite alternate; @@ -165,38 +167,47 @@ div.class-allow-device-to-connect-alert from { color: #a82a2a; } + to { color: #f55656; } } + @-moz-keyframes blink { from { color: #a82a2a; } + to { color: #f55656; } } + @-ms-keyframes blink { from { color: #a82a2a; } + to { color: #f55656; } } + @-o-keyframes blink { from { color: #a82a2a; } + to { color: #f55656; } } + @keyframes blink { from { color: #a82a2a; } + to { color: #f55656; } @@ -412,3 +423,19 @@ a:hover { text-decoration: none; cursor: pointer; } + +#new-version-header { + background: rgba(0, 255, 54, 0.48); + width: fit-content; + border-radius: 100px; + padding: 5px; +} + +#new-version-header:hover { + background: rgba(0, 255, 54, 0.78); + cursor: pointer; +} + +.bp3-tab-list { + height: calc(100vh - 30%); +} diff --git a/app/app.icns b/app/app.icns index 4f3cbba..008ec56 100644 Binary files a/app/app.icns and b/app/app.icns differ diff --git a/app/client/public/favicon.ico b/app/client/public/favicon.ico index bcd5dfd..2bf8e6a 100644 Binary files a/app/client/public/favicon.ico and b/app/client/public/favicon.ico differ diff --git a/app/client/public/index.html b/app/client/public/index.html index aa069f2..efb8a4e 100644 --- a/app/client/public/index.html +++ b/app/client/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + Deskreen Viewer diff --git a/app/client/public/logo192.png b/app/client/public/logo192.png index fc44b0a..00a38a8 100644 Binary files a/app/client/public/logo192.png and b/app/client/public/logo192.png differ diff --git a/app/client/public/logo512.png b/app/client/public/logo512.png index a4e47a6..c218ea5 100644 Binary files a/app/client/public/logo512.png and b/app/client/public/logo512.png differ diff --git a/app/client/public/manifest.json b/app/client/public/manifest.json index 080d6c7..d49ff86 100644 --- a/app/client/public/manifest.json +++ b/app/client/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Deskreen", + "name": "Deskreen Makes Any Device a Second Screen For Your Computer", "icons": [ { "src": "favicon.ico", diff --git a/app/client/sonar-project.properties b/app/client/sonar-project.properties index 7486edd..a760792 100644 --- a/app/client/sonar-project.properties +++ b/app/client/sonar-project.properties @@ -1,8 +1,8 @@ sonar.projectKey=deskreen-viewer sonar.typescript.lcov.reportPaths=coverage/lcov.info sonar.sources=src -sonar.cpd.exclusions=src/**/mocks/*,src/**/*.spec.ts,src/**/*.spec.tsx,src/**/*.test.ts,src/**/*.test.tsx,src/serviceWorker.ts,src/index.tsx -sonar.coverage.exclusions=src/**/mocks/*,src/**/*.spec.ts,src/**/*.spec.tsx,src/**/*.test.ts,src/**/*.test.tsx,src/serviceWorker.ts,src/index.tsx +sonar.cpd.exclusions=src/config/*,src/**/__mocks__/*,src/**/mocks/*,src/**/*.spec.ts,src/**/*.spec.tsx,src/**/*.test.ts,src/**/*.test.tsx,src/serviceWorker.ts,src/index.tsx +sonar.coverage.exclusions=src/config/*,src/**/__mocks__/*,src/**/mocks/*,src/**/*.spec.ts,src/**/*.spec.tsx,src/**/*.test.ts,src/**/*.test.tsx,src/serviceWorker.ts,src/index.tsx sonar.host.url=http://localhost:9000 sonar.login=e3b5f73b8778290f7074c40a4159c32b7f15a8e6 sonar.exclusions=src/serviceWorker.ts,node_modules/** diff --git a/app/client/src/components/PlayerControlPanel/__snapshots__/index.spec.tsx.snap b/app/client/src/components/PlayerControlPanel/__snapshots__/index.spec.tsx.snap index 48178af..0355f9b 100644 --- a/app/client/src/components/PlayerControlPanel/__snapshots__/index.spec.tsx.snap +++ b/app/client/src/components/PlayerControlPanel/__snapshots__/index.spec.tsx.snap @@ -23,6 +23,7 @@ exports[`should match exact snapshot 1`] = ` - - - - - ); -} diff --git a/app/components/SettingsOverlay/SettingsOverlay.tsx b/app/components/SettingsOverlay/SettingsOverlay.tsx index 3c3902a..51f530c 100644 --- a/app/components/SettingsOverlay/SettingsOverlay.tsx +++ b/app/components/SettingsOverlay/SettingsOverlay.tsx @@ -1,4 +1,7 @@ -import { remote } from 'electron'; +/* eslint-disable jsx-a11y/anchor-is-valid */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import { ipcRenderer, remote, shell } from 'electron'; import React, { useContext, useCallback, useEffect, useState } from 'react'; import { Button, @@ -6,6 +9,7 @@ import { Classes, H3, H6, + H4, Tabs, Tab, Icon, @@ -15,10 +19,10 @@ import { Checkbox, } from '@blueprintjs/core'; import { useTranslation } from 'react-i18next'; -import { Row } from 'react-flexbox-grid'; +import { Col, Row } from 'react-flexbox-grid'; import { createStyles, makeStyles } from '@material-ui/core/styles'; import i18n from 'i18next'; -import SharingSessionsService from '../../features/SharingSessionsService'; +import SharingSessionService from '../../features/SharingSessionService'; import { DARK_UI_BACKGROUND, LIGHT_UI_BACKGROUND, @@ -31,12 +35,15 @@ import i18n_client, { } from '../../configs/i18next.config.client'; import SettingRowLabelAndInput from './SettingRowLabelAndInput'; import isWithReactRevealAnimations from '../../utils/isWithReactRevealAnimations'; +import config from '../../api/config'; + +const { port } = config; const Fade = require('react-reveal/Fade'); -const sharingSessionsService = remote.getGlobal( +const sharingSessionService = remote.getGlobal( 'sharingSessionService' -) as SharingSessionsService; +) as SharingSessionService; interface SettingsOverlayProps { isSettingsOpen: boolean; @@ -63,6 +70,9 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { const { handleClose, isSettingsOpen } = props; + const [latestVersion, setLatestVersion] = useState(''); + const [currentVersion, setCurrentVersion] = useState(''); + const { isDarkTheme, setIsDarkThemeHook, @@ -71,6 +81,23 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { const [languagesList, setLanguagesList] = useState([] as string[]); + useEffect(() => { + const getLatestVersion = async () => { + const gotLatestVersion = await ipcRenderer.invoke('get-latest-version'); + if (gotLatestVersion !== '') { + setLatestVersion(gotLatestVersion); + } + }; + getLatestVersion(); + const getCurrentVersion = async () => { + const gotCurrentVersion = await ipcRenderer.invoke('get-current-version'); + if (gotCurrentVersion !== '') { + setCurrentVersion(gotCurrentVersion); + } + }; + getCurrentVersion(); + }, []); + useEffect(() => { const tmp: string[] = []; getLangFullNameToLangISOKeyMap().forEach((_, key) => { @@ -88,10 +115,10 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { setIsDarkThemeHook(true); } // TODO: call sharing sessions service here to notify all connected clients about theme change - sharingSessionsService.sharingSessions.forEach((sharingSession) => { + sharingSessionService.sharingSessions.forEach((sharingSession) => { sharingSession?.appThemeChanged(true); }); - sharingSessionsService.setAppTheme(true); + sharingSessionService.setAppTheme(true); }, [isDarkTheme, setIsDarkThemeHook]); const handleToggleLightTheme = useCallback(() => { @@ -100,10 +127,10 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { setIsDarkThemeHook(false); } // TODO: call sharing sessions service here to notify all connected clients about theme change - sharingSessionsService.sharingSessions.forEach((sharingSession) => { + sharingSessionService.sharingSessions.forEach((sharingSession) => { sharingSession?.appThemeChanged(false); }); - sharingSessionsService.setAppTheme(false); + sharingSessionService.setAppTheme(false); }, [isDarkTheme, setIsDarkThemeHook]); const onChangeLangueageHTMLSelectHandler = ( @@ -118,10 +145,10 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { 'English'; i18n.changeLanguage(newLang); // TODO: call sharing sessions service here to notify all connected clients about language change - sharingSessionsService.sharingSessions.forEach((sharingSession) => { + sharingSessionService.sharingSessions.forEach((sharingSession) => { sharingSession?.appLanguageChanged(newLang); }); - sharingSessionsService.setAppLanguage(newLang); + sharingSessionService.setAppLanguage(newLang); } }; @@ -147,6 +174,7 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { const getLanguageChangingHTMLSelect = () => { return ( { return ( ); }; @@ -209,6 +237,64 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { ); + const AboutPanel: React.FC = () => ( + +
+ + logo + + +

About Deskreen

+ + + {`Version: ${currentVersion} (${currentVersion})`} + + + + {`Copyright © ${new Date().getFullYear()} `} + { + shell.openExternal('https://linkedin.com/in/pavlobu'); + }} + style={ + isDarkTheme + ? {} + : { + color: 'blue', + } + } + > + Pavlo (Paul) Buidenkov + + + + + + {`Website: `} + { + shell.openExternal('https://www.deskreen.com'); + }} + style={ + isDarkTheme + ? {} + : { + color: 'blue', + } + } + > + https://www.deskreen.com + + + +
+
+ ); + const getTabNavBlockedIPsButton = () => { return ( @@ -245,6 +331,18 @@ export default function SettingsOverlay(props: SettingsOverlayProps) { ); }; + const getTabNavAboutButton = () => { + return ( + + + About + + ); + }; + return ( + {latestVersion !== '' && + currentVersion !== '' && + latestVersion !== currentVersion ? ( +

{ + e.preventDefault(); + shell.openExternal( + 'https://github.com/pavlobu/deskreen/releases/' + ); + }} + > + {/* eslint-disable-next-line react/jsx-one-expression-per-line */} + New version {latestVersion} is available! Click to download. +

+ ) : ( + <> + )} }> {getTabNavGeneralSettingsButton()} - }> + }> {getTabNavSecurityButton()} }> {getTabNavBlockedIPsButton()} + }> + {getTabNavAboutButton()} + diff --git a/app/components/SettingsOverlay/__snapshots__/SettingsOverlay.spec.tsx.snap b/app/components/SettingsOverlay/__snapshots__/SettingsOverlay.spec.tsx.snap index 8d588ed..acb31de 100644 --- a/app/components/SettingsOverlay/__snapshots__/SettingsOverlay.spec.tsx.snap +++ b/app/components/SettingsOverlay/__snapshots__/SettingsOverlay.spec.tsx.snap @@ -161,14 +161,13 @@ exports[`should match exact snapshot 1`] = ` +
@@ -424,9 +464,11 @@ exports[`should match exact snapshot 1`] = ` class="row" >
-
@@ -646,14 +688,13 @@ exports[`should match exact snapshot 1`] = ` +
@@ -909,9 +991,11 @@ exports[`should match exact snapshot 1`] = ` class="row" >
-
@@ -1294,7 +1378,7 @@ exports[`should match exact snapshot 1`] = ` - } + parentId="TabsExample" + selected={false} + title="" + > + + +
- Enabled + Disabled diff --git a/app/components/StepsOfStepper/ChooseAppOrScreenOverlay/ChooseAppOrScreenOverlay.tsx b/app/components/StepsOfStepper/ChooseAppOrScreenOverlay/ChooseAppOrScreenOverlay.tsx index 7a24970..0e6fb8b 100644 --- a/app/components/StepsOfStepper/ChooseAppOrScreenOverlay/ChooseAppOrScreenOverlay.tsx +++ b/app/components/StepsOfStepper/ChooseAppOrScreenOverlay/ChooseAppOrScreenOverlay.tsx @@ -1,6 +1,6 @@ import { remote } from 'electron'; -import React, { useEffect, useState } from 'react'; -import { H3, Card, Dialog } from '@blueprintjs/core'; +import React, { useCallback, useEffect, useState } from 'react'; +import { H3, Card, Dialog, Button } from '@blueprintjs/core'; import { Row, Col } from 'react-flexbox-grid'; import { createStyles, makeStyles } from '@material-ui/core/styles'; import CloseOverlayButton from '../../CloseOverlayButton'; @@ -69,7 +69,7 @@ export default function ChooseAppOrScreenOverlay( setAppsWindowsViewSharingObjectsMap, ] = useState>(new Map()); - useEffect(() => { + const handleRefreshSources = useCallback(() => { if (isEntireScreenToShareChosen) { const sourcesToShare = desktopCapturerSourcesService.getScreenSources(); const screenViewMap = new Map(); @@ -93,6 +93,10 @@ export default function ChooseAppOrScreenOverlay( } }, [isEntireScreenToShareChosen]); + useEffect(() => { + handleRefreshSources(); + }, [handleRefreshSources, isEntireScreenToShareChosen]); + return ( - + {isEntireScreenToShareChosen ? (

@@ -155,6 +159,18 @@ export default function ChooseAppOrScreenOverlay(

)} + + +

+
+ +
@@ -210,7 +244,7 @@ exports[`should match exact snapshot 1`] = ` style="width: 100%;" >

+
+ +
@@ -458,10 +526,10 @@ exports[`should match exact snapshot 1`] = ` } >
+ +
+ + + +
+ diff --git a/app/components/StepsOfStepper/ConfirmStep.tsx b/app/components/StepsOfStepper/ConfirmStep.tsx index 04cc284..3dd1e22 100644 --- a/app/components/StepsOfStepper/ConfirmStep.tsx +++ b/app/components/StepsOfStepper/ConfirmStep.tsx @@ -3,9 +3,9 @@ import { remote } from 'electron'; import { Text } from '@blueprintjs/core'; import { Row, Col } from 'react-flexbox-grid'; import SharingSourcePreviewCard from '../SharingSourcePreviewCard'; -import SharingSessionService from '../../features/SharingSessionsService'; +import SharingSessionService from '../../features/SharingSessionService'; import DeviceInfoCallout from '../DeviceInfoCallout'; -import SharingSession from '../../features/SharingSessionsService/SharingSession'; +import SharingSession from '../../features/SharingSessionService/SharingSession'; const sharingSessionService = remote.getGlobal( 'sharingSessionService' diff --git a/app/components/StepsOfStepper/IntermediateStep.tsx b/app/components/StepsOfStepper/IntermediateStep.tsx index dba9e00..7bda314 100644 --- a/app/components/StepsOfStepper/IntermediateStep.tsx +++ b/app/components/StepsOfStepper/IntermediateStep.tsx @@ -7,7 +7,7 @@ import ScanQRStep from './ScanQRStep'; import ChooseAppOrScreeenStep from './ChooseAppOrScreeenStep'; import ConfirmStep from './ConfirmStep'; import ConnectedDevicesService from '../../features/ConnectedDevicesService'; -import SharingSessionService from '../../features/SharingSessionsService'; +import SharingSessionService from '../../features/SharingSessionService'; const sharingSessionService = remote.getGlobal( 'sharingSessionService' diff --git a/app/components/StepsOfStepper/ScanQRStep.tsx b/app/components/StepsOfStepper/ScanQRStep.tsx index 6ac28ae..d55af1a 100644 --- a/app/components/StepsOfStepper/ScanQRStep.tsx +++ b/app/components/StepsOfStepper/ScanQRStep.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { clipboard, remote } from 'electron'; +import { clipboard, remote, ipcRenderer } from 'electron'; import React, { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -15,19 +15,17 @@ import { makeStyles, createStyles } from '@material-ui/core'; import { Row, Col } from 'react-flexbox-grid'; import { SettingsContext } from '../../containers/SettingsProvider'; import isProduction from '../../utils/isProduction'; -import SharingSessionService from '../../features/SharingSessionsService'; +import SharingSessionService from '../../features/SharingSessionService'; +import config from '../../api/config'; + +const { port } = config; const sharingSessionService = remote.getGlobal( 'sharingSessionService' ) as SharingSessionService; -const LOCAL_LAN_IP = - process.env.RUN_MODE === 'dev' || process.env.NODE_ENV === 'production' - ? require('internal-ip').v4.sync() - : '255.255.255.255'; - -// TODO: change 3131 to user defined port, if it is really defined -const CLIENT_VIEWER_PORT = isProduction() ? '3131' : '3000'; +// TODO: change port to user defined port, if it is really defined +const CLIENT_VIEWER_PORT = isProduction() ? port : '3000'; const useStyles = makeStyles(() => createStyles({ @@ -64,6 +62,7 @@ const ScanQRStep: React.FC = () => { const { isDarkTheme } = useContext(SettingsContext); const [roomID, setRoomID] = useState(''); + const [LOCAL_LAN_IP, setLocalLanIP] = useState(''); useEffect(() => { const thisInterval = setInterval(() => { @@ -74,8 +73,16 @@ const ScanQRStep: React.FC = () => { } }, 1000); + const ipInterval = setInterval(async () => { + const gotIP = await ipcRenderer.invoke('get-local-lan-ip'); + if (gotIP) { + setLocalLanIP(gotIP); + } + }, 1000); + return () => { clearInterval(thisInterval); + clearInterval(ipInterval); }; }, []); @@ -94,7 +101,7 @@ const ScanQRStep: React.FC = () => { borderRadius: '20px', }} > - make sure your computer and device are connected to same WiFi + Make sure your computer and device are connected to same WiFi {t('Scan the QR code')} @@ -114,8 +121,7 @@ const ScanQRStep: React.FC = () => { fgColor={isDarkTheme ? '#ffffff' : '#000000'} imageSettings={{ // TODO: change image to app icon - src: - 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Electron_Software_Framework_Logo.svg/256px-Electron_Software_Framework_Logo.svg.png', + src: `http://127.0.0.1:${CLIENT_VIEWER_PORT}/logo192.png`, width: 40, height: 40, }} @@ -171,8 +177,7 @@ const ScanQRStep: React.FC = () => { renderAs="svg" imageSettings={{ // TODO: change image to app icon - src: - 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Electron_Software_Framework_Logo.svg/256px-Electron_Software_Framework_Logo.svg.png', + src: `http://127.0.0.1:${CLIENT_VIEWER_PORT}/logo192.png`, width: 25, height: 25, }} diff --git a/app/components/StepsOfStepper/__snapshots__/IntermediateStep.spec.tsx.snap b/app/components/StepsOfStepper/__snapshots__/IntermediateStep.spec.tsx.snap index 54ce5cf..1fdea34 100644 --- a/app/components/StepsOfStepper/__snapshots__/IntermediateStep.spec.tsx.snap +++ b/app/components/StepsOfStepper/__snapshots__/IntermediateStep.spec.tsx.snap @@ -92,7 +92,7 @@ exports[`should match exact snapshot on each step 1`] = ` } } > - make sure your computer and device are connected to same WiFi + Make sure your computer and device are connected to same WiFi
@@ -198,7 +198,7 @@ exports[`should match exact snapshot on each step 1`] = ` imageSettings={ Object { "height": 40, - "src": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Electron_Software_Framework_Logo.svg/256px-Electron_Software_Framework_Logo.svg.png", + "src": "http://127.0.0.1:3000/logo192.png", "width": 40, } } @@ -206,7 +206,7 @@ exports[`should match exact snapshot on each step 1`] = ` level="H" renderAs="svg" size={128} - value="http://255.255.255.255:3000/" + value="http://:3000/" > @@ -414,7 +414,7 @@ exports[`should match exact snapshot on each step 1`] = ` className="bp3-button-text" key="text" > - http://255.255.255.255:3000/ + http://:3000/ when rendered should match exact snapshot 1`] = ` } } > - make sure your computer and device are connected to same WiFi + Make sure your computer and device are connected to same WiFi when rendered should match exact snapshot 1`] = ` imageSettings={ Object { "height": 40, - "src": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Electron_Software_Framework_Logo.svg/256px-Electron_Software_Framework_Logo.svg.png", + "src": "http://127.0.0.1:3000/logo192.png", "width": 40, } } @@ -61,7 +61,7 @@ exports[` when rendered should match exact snapshot 1`] = ` level="H" renderAs="svg" size={128} - value="http://255.255.255.255:3000/" + value="http://:3000/" /> @@ -99,7 +99,7 @@ exports[` when rendered should match exact snapshot 1`] = ` } } > - http://255.255.255.255:3000/ + http://:3000/ when rendered should match exact snapshot 1`] = ` imageSettings={ Object { "height": 25, - "src": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Electron_Software_Framework_Logo.svg/256px-Electron_Software_Framework_Logo.svg.png", + "src": "http://127.0.0.1:3000/logo192.png", "width": 25, } } @@ -144,7 +144,7 @@ exports[` when rendered should match exact snapshot 1`] = ` level="H" renderAs="svg" size={128} - value="http://255.255.255.255:3000/" + value="http://:3000/" width="390px" /> diff --git a/app/components/TopPanel.tsx b/app/components/TopPanel.tsx index f609283..ec204df 100644 --- a/app/components/TopPanel.tsx +++ b/app/components/TopPanel.tsx @@ -1,6 +1,7 @@ /* eslint-disable react/destructuring-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-hooks/rules-of-hooks */ +import { shell } from 'electron'; import React, { useCallback, useContext } from 'react'; import { Button, Text, Icon, Position, Tooltip } from '@blueprintjs/core'; import { createStyles, makeStyles } from '@material-ui/core/styles'; @@ -80,10 +81,12 @@ export default function TopPanel(props: any) { >
); diff --git a/app/components/__snapshots__/TopPanel.spec.tsx.snap b/app/components/__snapshots__/TopPanel.spec.tsx.snap index a357050..652ca38 100644 --- a/app/components/__snapshots__/TopPanel.spec.tsx.snap +++ b/app/components/__snapshots__/TopPanel.spec.tsx.snap @@ -25,11 +25,11 @@ exports[` should match exact snapshot 1`] = ` transitionDuration={100} > @@ -86,12 +86,27 @@ exports[` should match exact snapshot 1`] = ` position="bottom" transitionDuration={100} > -

- Deskreen -

+

+ Deskreen +

+
@@ -139,6 +154,7 @@ exports[` should match exact snapshot 1`] = ` className="makeStyles-topPanelControlButton-61" id="top-panel-help-button" intent="none" + onClick={[Function]} > { + beforeEach(() => {}); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when getLangFullNameToLangISOKeyMap called', () => { + it('should return proper key map', () => { + // TODO: add more languages here manually when adding new languages in app! + const expectedMap = new Map(); + expectedMap.set('English', 'en'); + expectedMap.set('Русский', 'ru'); + expectedMap.set('Українська', 'ua'); + + const res = getLangFullNameToLangISOKeyMap(); + + expect(res).toEqual(expectedMap); + }); + }); + + describe('when getLangISOKeyToLangFullNameMap called', () => { + it('should return proper key map', () => { + // TODO: add more languages here manually when adding new languages in app! + const expectedMap = new Map(); + expectedMap.set('en', 'English'); + expectedMap.set('ru', 'Русский'); + expectedMap.set('ua', 'Українська'); + + const res = getLangISOKeyToLangFullNameMap(); + + expect(res).toEqual(expectedMap); + }); + }); +}); diff --git a/app/configs/i18next.config.client.ts b/app/configs/i18next.config.client.ts index 36837e8..f3bb1d7 100644 --- a/app/configs/i18next.config.client.ts +++ b/app/configs/i18next.config.client.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ + /* eslint-disable @typescript-eslint/ban-ts-comment */ import { remote, ipcRenderer } from 'electron'; import i18n from 'i18next'; @@ -8,7 +10,6 @@ import settings from 'electron-settings'; import config from './app.lang.config'; import isProduction from '../utils/isProduction'; -// TODO: move this outside this file! export const getLangFullNameToLangISOKeyMap = (): Map => { const res = new Map(); // eslint-disable-next-line no-restricted-syntax @@ -20,7 +21,6 @@ export const getLangFullNameToLangISOKeyMap = (): Map => { return res; }; -// TODO: move this outside this file! export const getLangISOKeyToLangFullNameMap = (): Map => { const res = new Map(); // eslint-disable-next-line no-restricted-syntax diff --git a/app/configs/i18next.config.ts b/app/configs/i18next.config.ts index 941d4e1..e385b23 100644 --- a/app/configs/i18next.config.ts +++ b/app/configs/i18next.config.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ + import i18n from 'i18next'; import i18nextBackend from 'i18next-node-fs-backend'; import { join } from 'path'; diff --git a/app/containers/CounterPage.tsx b/app/containers/CounterPage.tsx deleted file mode 100644 index a342ea7..0000000 --- a/app/containers/CounterPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import TopPanel from '../components/TopPanel'; -import Counter from '../features/counter/Counter'; - -export default function CounterPage() { - return ( - <> - - - - ); -} diff --git a/app/containers/DeskreenStepper.tsx b/app/containers/DeskreenStepper.tsx index 39b31de..798e649 100644 --- a/app/containers/DeskreenStepper.tsx +++ b/app/containers/DeskreenStepper.tsx @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import React, { useState, useCallback, useContext, useEffect } from 'react'; -import { remote } from 'electron'; +import { ipcRenderer, remote } from 'electron'; import { makeStyles, createStyles } from '@material-ui/core/styles'; import Stepper from '@material-ui/core/Stepper'; import Step from '@material-ui/core/Step'; import StepLabel from '@material-ui/core/StepLabel'; -import { Row, Col } from 'react-flexbox-grid'; -import { Text } from '@blueprintjs/core'; +import { Row, Col, Grid } from 'react-flexbox-grid'; +import { Dialog, H3, H4, H5, Icon, Spinner, Text } from '@blueprintjs/core'; import { useToasts } from 'react-toast-notifications'; @@ -19,9 +19,9 @@ import ColorlibStepIcon, { } from '../components/StepperPanel/ColorlibStepIcon'; import ColorlibConnector from '../components/StepperPanel/ColorlibConnector'; import { SettingsContext } from './SettingsProvider'; -import SharingSessionService from '../features/SharingSessionsService'; +import SharingSessionService from '../features/SharingSessionService'; import ConnectedDevicesService from '../features/ConnectedDevicesService'; -import SharingSessionStatusEnum from '../features/SharingSessionsService/SharingSessionStatusEnum'; +import SharingSessionStatusEnum from '../features/SharingSessionService/SharingSessionStatusEnum'; import Logger from '../utils/LoggerWithFilePrefix'; const log = new Logger(__filename); @@ -65,12 +65,28 @@ const DeskreenStepper = React.forwardRef((_props, ref) => { const [isAlertOpen, setIsAlertOpen] = useState(false); const [isUserAllowedConnection, setIsUserAllowedConnection] = useState(false); + const [isNoWiFiError, setisNoWiFiError] = useState(false); const [ pendingConnectionDevice, setPendingConnectionDevice, ] = useState(null); + useEffect(() => { + const ipInterval = setInterval(async () => { + const gotIP = await ipcRenderer.invoke('get-local-lan-ip'); + if (gotIP === undefined) { + setisNoWiFiError(true); + } else { + setisNoWiFiError(false); + } + }, 1000); + + return () => { + clearInterval(ipInterval); + }; + }, []); + useEffect(() => { sharingSessionService .createWaitingForConnectionSharingSession() @@ -153,7 +169,7 @@ const DeskreenStepper = React.forwardRef((_props, ref) => { const sharingSession = sharingSessionService.waitingForConnectionSharingSession; sharingSession?.disconnectByHostMachineUser(); - sharingSession?.destory(); + sharingSession?.destroy(); sharingSessionService.sharingSessions.delete(sharingSession?.id as string); sharingSessionService.waitingForConnectionSharingSession = null; @@ -183,7 +199,7 @@ const DeskreenStepper = React.forwardRef((_props, ref) => { const sharingSession = sharingSessionService.waitingForConnectionSharingSession; sharingSession.denyConnectionForPartner(); - sharingSession.destory(); + sharingSession.destroy(); sharingSession.setStatus(SharingSessionStatusEnum.NOT_CONNECTED); sharingSessionService.sharingSessions.delete(sharingSession.id); @@ -328,6 +344,27 @@ const DeskreenStepper = React.forwardRef((_props, ref) => { onCancel={handleCancelAlert} onConfirm={handleConfirmAlert} /> + + +
+ + + + +

No WiFi and LAN connection.

+
+ +
Deskreen works only with WiFi and LAN networks.
+
+ + + + +

Waiting for connection.

+
+
+
+
); }); diff --git a/app/containers/__mocks__/electron.ts b/app/containers/__mocks__/electron.ts index 1d85ddf..b721f21 100644 --- a/app/containers/__mocks__/electron.ts +++ b/app/containers/__mocks__/electron.ts @@ -8,3 +8,4 @@ export const remote = { return ''; }, }; +export const ipcRenderer = jest.fn(); diff --git a/app/containers/__snapshots__/DeskreenStepper.spec.tsx.snap b/app/containers/__snapshots__/DeskreenStepper.spec.tsx.snap index e4b0839..32a7615 100644 --- a/app/containers/__snapshots__/DeskreenStepper.spec.tsx.snap +++ b/app/containers/__snapshots__/DeskreenStepper.spec.tsx.snap @@ -998,7 +998,7 @@ exports[`should match exact snapshot 1`] = ` } } > - make sure your computer and device are connected to same WiFi + Make sure your computer and device are connected to same WiFi
@@ -1104,7 +1104,7 @@ exports[`should match exact snapshot 1`] = ` imageSettings={ Object { "height": 40, - "src": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Electron_Software_Framework_Logo.svg/256px-Electron_Software_Framework_Logo.svg.png", + "src": "http://127.0.0.1:3000/logo192.png", "width": 40, } } @@ -1112,7 +1112,7 @@ exports[`should match exact snapshot 1`] = ` level="H" renderAs="svg" size={128} - value="http://255.255.255.255:3000/" + value="http://:3000/" > @@ -1320,7 +1320,7 @@ exports[`should match exact snapshot 1`] = ` className="bp3-button-text" key="text" > - http://255.255.255.255:3000/ + http://:3000/ + + + diff --git a/app/containers/__snapshots__/HomePage.spec.tsx.snap b/app/containers/__snapshots__/HomePage.spec.tsx.snap index 0c0ead7..3b5bd13 100644 --- a/app/containers/__snapshots__/HomePage.spec.tsx.snap +++ b/app/containers/__snapshots__/HomePage.spec.tsx.snap @@ -146,11 +146,11 @@ exports[`should match exact snapshot 1`] = ` -

- Deskreen -

+ +
@@ -583,12 +625,14 @@ exports[`should match exact snapshot 1`] = ` id="top-panel-help-button" intent="none" key=".0" + onClick={[Function]} tabIndex={0} >
@@ -1982,7 +2026,7 @@ exports[`should match exact snapshot 1`] = ` imageSettings={ Object { "height": 40, - "src": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Electron_Software_Framework_Logo.svg/256px-Electron_Software_Framework_Logo.svg.png", + "src": "http://127.0.0.1:3000/logo192.png", "width": 40, } } @@ -1990,7 +2034,7 @@ exports[`should match exact snapshot 1`] = ` level="H" renderAs="svg" size={128} - value="http://255.255.255.255:3000/" + value="http://:3000/" > @@ -2198,7 +2242,7 @@ exports[`should match exact snapshot 1`] = ` className="bp3-button-text" key="text" > - http://255.255.255.255:3000/ + http://:3000/ + + + { + let service: ConnectedDevicesService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + service = new ConnectedDevicesService(); + }); + + describe('when ConnectedDevicesService created properly', () => { + describe('when .resetPendingConnectionDevice() was called', () => { + it('should set .pendingConnectionDevice to nullDevice', () => { + service.pendingConnectionDevice = testDevice; + service.resetPendingConnectionDevice(); + + expect(service.pendingConnectionDevice).toBe(nullDevice); + }); + }); + + describe('when .getDevices() was called', () => { + it('should return .devices array', () => { + const res = service.getDevices(); + + expect(res).toBe(service.devices); + }); + }); + + describe('when .removeAllDevices() was called', () => { + it('should make .devices array empty', () => { + service.devices.push(testDevice); + + service.removeAllDevices(); + + expect(service.devices.length).toBe(0); + }); + }); + + describe('when .removeDeviceByID() was called', () => { + it('should remove appropriate device from .devices array', async () => { + const testDevice2 = (JSON.parse( + JSON.stringify(testDevice) + ) as unknown) as Device; + service.devices.push(testDevice); + service.devices.push(testDevice2); + + await service.removeDeviceByID(testDevice.id); + + let isStillInArray = false; + service.devices.forEach((d) => { + if (d.id === testDevice.id) { + isStillInArray = true; + } + }); + expect(isStillInArray).toBe(false); + }); + }); + + describe('when .addDevice() was called', () => { + it('should add device to .devices array', () => { + service.addDevice(testDevice); + + let isInArray = false; + service.devices.forEach((d) => { + if (d.id === testDevice.id) { + isInArray = true; + } + }); + expect(isInArray).toBe(true); + }); + }); + + describe('when .addPendingConnectedDeviceListener() was called', () => { + it('should add listener to .pendingDeviceConnectedListeners array', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const testCallback = (_: Device) => {}; + + service.addPendingConnectedDeviceListener(testCallback); + + let isInArray = false; + service.pendingDeviceConnectedListeners.forEach((c) => { + if (c === testCallback) { + isInArray = true; + } + }); + expect(isInArray).toBe(true); + }); + }); + + describe('when .setPendingConnectionDevice() was called', () => { + it('should set passed device as pendingConnectionDevice adn call .emitPendingConnectionDeviceConnected', () => { + service.emitPendingConnectionDeviceConnected = jest.fn(); + + service.setPendingConnectionDevice(testDevice); + + expect(service.pendingConnectionDevice).toBe(testDevice); + expect(service.emitPendingConnectionDeviceConnected).toBeCalled(); + }); + }); + + describe('when .emitPendingConnectionDeviceConnected() was called', () => { + it('should call all callbacks in pendingDeviceConnectedListeners', () => { + const testCallback1 = jest.fn(); + const testCallback2 = jest.fn(); + service.pendingDeviceConnectedListeners = [ + testCallback1, + testCallback2, + ]; + + service.emitPendingConnectionDeviceConnected(); + + expect(testCallback1).toBeCalled(); + expect(testCallback2).toBeCalled(); + }); + }); + }); +}); diff --git a/app/features/ConnectedDevicesService/index.ts b/app/features/ConnectedDevicesService/index.ts index f656ed7..686b018 100644 --- a/app/features/ConnectedDevicesService/index.ts +++ b/app/features/ConnectedDevicesService/index.ts @@ -1,4 +1,4 @@ -const nullDevice: Device = { +export const nullDevice: Device = { id: '', sharingSessionID: '', deviceOS: '', @@ -33,7 +33,7 @@ class ConnectedDevices { this.devices = this.devices.filter((d) => { return d.id !== deviceIDToRemove; }); - resolve(); + resolve(undefined); }); } @@ -50,7 +50,7 @@ class ConnectedDevices { this.emitPendingConnectionDeviceConnected(); } - private emitPendingConnectionDeviceConnected() { + emitPendingConnectionDeviceConnected() { this.pendingDeviceConnectedListeners.forEach( (callback: (device: Device) => void) => { callback(this.pendingConnectionDevice); diff --git a/app/features/DesktopCapturerSourcesService/DesktopCapturerSource.d.ts b/app/features/DesktopCapturerSourcesService/DesktopCapturerSource.d.ts deleted file mode 100644 index 868e2a0..0000000 --- a/app/features/DesktopCapturerSourcesService/DesktopCapturerSource.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import DesktopCapturerSourceType from './DesktopCapturerSourceType'; - -interface DesktopCapturerSource { - id: string; - type: DesktopCapturerSourceType; - name: string; -} diff --git a/app/features/DesktopCapturerSourcesService/DesktopCapturerSourceType.ts b/app/features/DesktopCapturerSourcesService/DesktopCapturerSourceType.ts index 8d0fbaa..a6abe56 100644 --- a/app/features/DesktopCapturerSourcesService/DesktopCapturerSourceType.ts +++ b/app/features/DesktopCapturerSourcesService/DesktopCapturerSourceType.ts @@ -1,12 +1,6 @@ enum DesktopCapturerSourceType { - WINDOW, - SCREEN, + WINDOW = 'window', + SCREEN = 'screen', } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const getDesktopCapturerSourceTypeFromSourceID = (_id: string) => { - // TODO: implement this function! - return DesktopCapturerSourceType.WINDOW; -}; - export default DesktopCapturerSourceType; diff --git a/app/features/DesktopCapturerSourcesService/DesktopCapturerSourceWithType.d.ts b/app/features/DesktopCapturerSourcesService/DesktopCapturerSourceWithType.d.ts new file mode 100644 index 0000000..8538685 --- /dev/null +++ b/app/features/DesktopCapturerSourcesService/DesktopCapturerSourceWithType.d.ts @@ -0,0 +1,4 @@ +interface DesktopCapturerSourceWithType { + source: import('electron').DesktopCapturerSource; + type: import('./DesktopCapturerSourceType').default; +} diff --git a/app/features/DesktopCapturerSourcesService/index.spec.ts b/app/features/DesktopCapturerSourcesService/index.spec.ts new file mode 100644 index 0000000..ccbf690 --- /dev/null +++ b/app/features/DesktopCapturerSourcesService/index.spec.ts @@ -0,0 +1,310 @@ +import path from 'path'; +import { DesktopCapturerSource } from 'electron'; +import DesktopCapturerSources, { getSourceTypeFromSourceID } from '.'; +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import Logger from '../../utils/LoggerWithFilePrefix'; +import DesktopCapturerSourceType from './DesktopCapturerSourceType'; + +jest.useFakeTimers(); +jest.mock('../../utils/LoggerWithFilePrefix'); // Logger is now a mock constructor +jest.mock('.', () => ({ + __esModule: true, // this property makes it work + default: jest.fn(), + getSourceTypeFromSourceID: jest.requireActual('.').getSourceTypeFromSourceID, +})); +const testScreenSource1Name = 'screen:1234'; +const testScreenSource2Name = 'screen:4321'; +const testWindowSource1Name = 'window:1234'; +const testWindowSource2Name = 'window:4321'; +const testScreenSource1 = { + type: DesktopCapturerSourceType.SCREEN, + source: ({ + id: 'screen:adfe2', + display_id: '82392', + } as unknown) as DesktopCapturerSource, +}; +const testScreenSource2 = { + type: DesktopCapturerSourceType.SCREEN, + source: ({ + id: 'screen:adfe212', + display_id: '123234', + } as unknown) as DesktopCapturerSource, +}; +const testWindowSource1 = { + type: DesktopCapturerSourceType.WINDOW, + source: ({ + id: 'window:a42323', + display_id: '82392', + } as unknown) as DesktopCapturerSource, +}; +const testWindowSource2 = { + type: DesktopCapturerSourceType.WINDOW, + source: ({ + id: 'window:adfe83292', + display_id: '123234', + } as unknown) as DesktopCapturerSource, +}; +jest.mock('electron', () => { + // eslint-disable-next-line global-require + const testScreenSource1a = ({ + id: 'screen:adfe2', + display_id: '82392', + } as unknown) as DesktopCapturerSource; + const testScreenSource2a = ({ + id: 'screen:adfe212', + display_id: '123234', + } as unknown) as DesktopCapturerSource; + const testWindowSource1a = ({ + id: 'window:a42323', + display_id: '82392', + } as unknown) as DesktopCapturerSource; + const testWindowSource2a = ({ + id: 'window:adfe83292', + display_id: '123234', + } as unknown) as DesktopCapturerSource; + return { + // __esModule: true, + desktopCapturer: { + getSources: () => { + return new Promise((resolve) => { + resolve([ + testScreenSource1a, + testWindowSource1a, + testScreenSource2a, + testWindowSource2a, + ]); + }); + }, + }, + }; +}); + +describe('DesktopCapturerSourcesService tests', () => { + let desktopCapturerService: DesktopCapturerSources; + + beforeEach(() => { + // Clear all instances and calls to constructor and all methods: + // @ts-ignore + Logger.mockClear(); + // @ts-ignore + DesktopCapturerSources.mockClear(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when DesktopCapturerSourcesService was created properly', () => { + it('should create logger properly', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + + expect(Logger).toHaveBeenCalledTimes(1); + expect(Logger).toHaveBeenCalledWith(path.join(__dirname, 'index.ts')); + }); + + it('should call startRefreshDesktopCapturerSourcesLoop', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + desktopCapturerService.startRefreshDesktopCapturerSourcesLoop = jest.fn(); + + desktopCapturerService.constructor(); + + expect( + desktopCapturerService.startRefreshDesktopCapturerSourcesLoop + ).toHaveBeenCalledTimes(1); + }); + + it('should call refreshDesktopCapturerSources multiple times', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + + desktopCapturerService.refreshDesktopCapturerSources = jest.fn(); + + for (let i = 1; i < 5; i += 1) { + jest.advanceTimersByTime(6000); + + expect( + desktopCapturerService.refreshDesktopCapturerSources + ).toHaveBeenCalledTimes(i); + } + }); + + it('should call startPollForInactiveListenersLoop', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + desktopCapturerService.startPollForInactiveListenersLoop = jest.fn(); + + desktopCapturerService.constructor(); + + expect( + desktopCapturerService.startPollForInactiveListenersLoop + ).toHaveBeenCalledTimes(1); + }); + + describe('when .getSourcesMap was called', () => { + it('should return a sources map object', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + + const res = desktopCapturerService.getSourcesMap(); + + expect(desktopCapturerService.sources).toBe(res); + }); + }); + + describe('when .getScreenSources was called', () => { + it('should return sources array which are of type SCREEN only', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + desktopCapturerService.sources.set( + testScreenSource1Name, + testScreenSource1 + ); + desktopCapturerService.sources.set( + testScreenSource2Name, + testScreenSource2 + ); + desktopCapturerService.sources.set( + testWindowSource1Name, + testWindowSource1 + ); + desktopCapturerService.sources.set( + testWindowSource2Name, + testWindowSource2 + ); + + const res = desktopCapturerService.getScreenSources(); + + expect(res).toEqual([ + testScreenSource1.source, + testScreenSource2.source, + ]); + }); + }); + + describe('when .getAppWindowSources was called', () => { + it('should return sources array which are of type WINDOW only', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + desktopCapturerService.sources.set( + testScreenSource1Name, + testScreenSource1 + ); + desktopCapturerService.sources.set( + testScreenSource2Name, + testScreenSource2 + ); + desktopCapturerService.sources.set( + testWindowSource1Name, + testWindowSource1 + ); + desktopCapturerService.sources.set( + testWindowSource2Name, + testWindowSource2 + ); + + const res = desktopCapturerService.getAppWindowSources(); + + expect(res).toEqual([ + testWindowSource1.source, + testWindowSource2.source, + ]); + }); + }); + + describe('when .getSourceDisplayIDBySourceID was called', () => { + it('should return proper source display_id string', () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + desktopCapturerService.sources.set( + testScreenSource1Name, + testScreenSource1 + ); + desktopCapturerService.sources.set( + testScreenSource2Name, + testScreenSource2 + ); + desktopCapturerService.sources.set( + testWindowSource1Name, + testWindowSource1 + ); + desktopCapturerService.sources.set( + testWindowSource2Name, + testWindowSource2 + ); + + const res = desktopCapturerService.getSourceDisplayIDBySourceID( + testScreenSource1.source.id + ); + + expect(res).toEqual(testScreenSource1.source.display_id); + }); + }); + + describe('when .getDesktopCapturerSources was called', () => { + it('should resolve with proper map of screen sources', async () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + const testSourcesMap = new Map(); + testSourcesMap.set(testScreenSource1.source.id, testScreenSource1); + testSourcesMap.set(testScreenSource2.source.id, testScreenSource2); + testSourcesMap.set(testWindowSource1.source.id, testWindowSource1); + testSourcesMap.set(testWindowSource2.source.id, testWindowSource2); + + const res = await desktopCapturerService.getDesktopCapturerSources(); + + expect(res).toEqual(testSourcesMap); + }); + }); + + describe('when .refreshDesktopCapturerSources was called', () => { + it('should call proper methods to check whether windows are closed and screens disconnected', async () => { + const DesktopCapturerSourcesClass = jest.requireActual('.') + .default as DesktopCapturerSources; + // @ts-ignore + desktopCapturerService = new DesktopCapturerSourcesClass(); + desktopCapturerService.checkForClosedWindows = jest.fn(); + desktopCapturerService.checkForScreensDisconnected = jest.fn(); + desktopCapturerService.constructor(); + + await desktopCapturerService.refreshDesktopCapturerSources(); + + expect(desktopCapturerService.checkForClosedWindows).toBeCalled(); + expect(desktopCapturerService.checkForScreensDisconnected).toBeCalled(); + }); + }); + }); +}); + +describe('getSourceTypeFromSourceID tests', () => { + it('should return proper source type depending on input type', () => { + const testWindowSource = 'window:1234'; + const testScreenSource = 'screen:4321'; + + expect(getSourceTypeFromSourceID(testWindowSource)).toBe( + DesktopCapturerSourceType.WINDOW + ); + expect(getSourceTypeFromSourceID(testScreenSource)).toBe( + DesktopCapturerSourceType.SCREEN + ); + }); +}); diff --git a/app/features/DesktopCapturerSourcesService/index.ts b/app/features/DesktopCapturerSourcesService/index.ts index be1d941..f28eb8b 100644 --- a/app/features/DesktopCapturerSourcesService/index.ts +++ b/app/features/DesktopCapturerSourcesService/index.ts @@ -5,19 +5,15 @@ import { desktopCapturer, DesktopCapturerSource } from 'electron'; import Logger from '../../utils/LoggerWithFilePrefix'; import DesktopCapturerSourceType from './DesktopCapturerSourceType'; -const log = new Logger(__filename); - -const getSourceTypeFromSourceID = (id: string): DesktopCapturerSourceType => { - if (id.includes('screen')) { +export function getSourceTypeFromSourceID( + id: string +): DesktopCapturerSourceType { + if (id.includes(DesktopCapturerSourceType.SCREEN)) { return DesktopCapturerSourceType.SCREEN; } return DesktopCapturerSourceType.WINDOW; -}; +} -type DesktopCapturerSourceWithType = { - type: DesktopCapturerSourceType; - source: DesktopCapturerSource; -}; type SourcesDisappearListener = (ids: string[]) => void; type SharingSessionID = string; @@ -35,6 +31,8 @@ class DesktopCapturerSources { SourcesDisappearListener[] >; + log = new Logger(__filename); + constructor() { this.sources = new Map(); this.lastAvailableScreenIDs = []; @@ -48,18 +46,20 @@ class DesktopCapturerSources { SourcesDisappearListener[] >(); - setTimeout(() => { - setInterval(() => { - this.refreshDesktopCapturerSources(); - }, 5000); - }, 4000); - this.pollForInactiveListeners(); + this.startRefreshDesktopCapturerSourcesLoop(); + this.startPollForInactiveListenersLoop(); } getSourcesMap(): Map { return this.sources; } + startRefreshDesktopCapturerSourcesLoop() { + setInterval(() => { + this.refreshDesktopCapturerSources(); + }, 5000); + } + getScreenSources(): DesktopCapturerSource[] { const screenSources: DesktopCapturerSource[] = []; [...this.sources.keys()].forEach((key) => { @@ -110,25 +110,27 @@ class DesktopCapturerSources { // TODO: implement logic } - private async updateDesktopCapturerSources() { - this.lastAvailableScreenIDs = []; - this.lastAvailableWindowIDs = []; + async updateDesktopCapturerSources() { + // TODO: implement logic of checking if last sources match new sources, + // TODO: if source is gone, do proper actions and notify user if needed + // this.lastAvailableScreenIDs = []; + // this.lastAvailableWindowIDs = []; - [...this.sources.keys()].forEach((key) => { - const oldSource = this.sources.get(key); - if (!oldSource) return; - if (oldSource.type === DesktopCapturerSourceType.WINDOW) { - this.lastAvailableWindowIDs.push(oldSource.source.id); - } else if (oldSource.type === DesktopCapturerSourceType.SCREEN) { - this.lastAvailableScreenIDs.push(oldSource.source.id); - } - }); + // [...this.sources.keys()].forEach((key) => { + // const oldSource = this.sources.get(key); + // if (!oldSource) return; + // if (oldSource.type === DesktopCapturerSourceType.WINDOW) { + // this.lastAvailableWindowIDs.push(oldSource.source.id); + // } else if (oldSource.type === DesktopCapturerSourceType.SCREEN) { + // this.lastAvailableScreenIDs.push(oldSource.source.id); + // } + // }); this.sources = await this.getDesktopCapturerSources(); } // eslint-disable-next-line class-methods-use-this - private getDesktopCapturerSources(): Promise< + getDesktopCapturerSources(): Promise< Map > { return new Promise>( @@ -136,27 +138,19 @@ class DesktopCapturerSources { const newSources = new Map(); try { const capturerSources = await desktopCapturer.getSources({ - types: ['window', 'screen'], + types: [ + DesktopCapturerSourceType.WINDOW, + DesktopCapturerSourceType.SCREEN, + ], thumbnailSize: { width: 500, height: 500 }, fetchWindowIcons: true, // TODO: use window icons in app UI ! }); - // for (const source of capturerSources) { - // newSources.set(source.id, { - // type: getSourceTypeFromSourceID(source.id), - // source, - // }); - // } - capturerSources.forEach((source) => { newSources.set(source.id, { type: getSourceTypeFromSourceID(source.id), source, }); }); - // .catch((e) => { - // console.error(e); - // throw new Error('error getting desktopCapturer sources'); - // }); resolve(newSources); } catch (e) { reject(); @@ -165,56 +159,49 @@ class DesktopCapturerSources { ); } - private refreshDesktopCapturerSources() { + async refreshDesktopCapturerSources() { // TODO: implement get available sources logic here; - this.updateDesktopCapturerSources() + try { + await this.updateDesktopCapturerSources(); // eslint-disable-next-line promise/always-return - .then(() => { - // eventually run checkers that emit events - this.checkForClosedWindows(); - this.checkForScreensDisconnected(); - }) - .catch((e) => { - log.error(e); - }); + // eventually run checkers that emit events + this.checkForClosedWindows(); + this.checkForScreensDisconnected(); + } catch (e) { + this.log.error(e); + } } - private pollForInactiveListeners() { - // TODO: implement logic - // if session ID no longer exists in SharingSessionsService -> remove its listener object - - setTimeout(() => { - this.pollForInactiveListeners(); + startPollForInactiveListenersLoop() { + setInterval(() => { + // TODO: implement logic + // if session ID no longer exists in SharingSessionsService -> remove its listener object }, 1000 * 60 * 60); // runs every hour in infinite loop } - private checkForClosedWindows() { + checkForClosedWindows() { // TODO: implement logic - const isSomeWindowsClosed = false; - const closedWindowsIDs: string[] = []; - - if (isSomeWindowsClosed) { - this.notifyOnWindowsClosedListeners(closedWindowsIDs); - } + // const isSomeWindowsClosed = false; + // const closedWindowsIDs: string[] = []; + // if (isSomeWindowsClosed) { + // this.notifyOnWindowsClosedListeners(closedWindowsIDs); + // } } - private notifyOnWindowsClosedListeners(_closedWindowsIDs: string[]) { + notifyOnWindowsClosedListeners(_closedWindowsIDs: string[]) { // TODO: implement logic } - private checkForScreensDisconnected() { + checkForScreensDisconnected() { // TODO: implement logic - const isSomeScreensDisconnected = false; - const disconnectedScreensIDs: string[] = []; - - if (isSomeScreensDisconnected) { - this.notifyOnScreensDisconnectedListeners(disconnectedScreensIDs); - } + // const isSomeScreensDisconnected = false; + // const disconnectedScreensIDs: string[] = []; + // if (isSomeScreensDisconnected) { + // this.notifyOnScreensDisconnectedListeners(disconnectedScreensIDs); + // } } - private notifyOnScreensDisconnectedListeners( - _disconnectedScreensIDs: string[] - ) { + notifyOnScreensDisconnectedListeners(_disconnectedScreensIDs: string[]) { // TODO: implement logic } } diff --git a/app/features/PeerConnection/NullSimplePeer.ts b/app/features/PeerConnection/NullSimplePeer.ts new file mode 100644 index 0000000..64f85a5 --- /dev/null +++ b/app/features/PeerConnection/NullSimplePeer.ts @@ -0,0 +1,5 @@ +import SimplePeer from 'simple-peer'; + +const NullSimplePeer = new SimplePeer(); + +export default NullSimplePeer; diff --git a/app/features/PeerConnection/NullUser.ts b/app/features/PeerConnection/NullUser.ts new file mode 100644 index 0000000..e220d15 --- /dev/null +++ b/app/features/PeerConnection/NullUser.ts @@ -0,0 +1 @@ +export default { username: '', publicKey: '', privateKey: '' }; diff --git a/app/features/PeerConnection/PartnerPeerUser.d.ts b/app/features/PeerConnection/PartnerPeerUser.d.ts new file mode 100644 index 0000000..b2aebca --- /dev/null +++ b/app/features/PeerConnection/PartnerPeerUser.d.ts @@ -0,0 +1,4 @@ +interface PartnerPeerUser { + username: string; + publicKey: string; +} diff --git a/app/features/PeerConnection/PeerConnection.d.ts b/app/features/PeerConnection/PeerConnection.d.ts new file mode 100644 index 0000000..fdcf25e --- /dev/null +++ b/app/features/PeerConnection/PeerConnection.d.ts @@ -0,0 +1,3 @@ +// use import() to prevent cycle import! +// From here: https://stackoverflow.com/questions/39040108/import-class-in-definition-file-d-ts +type PeerConnection = import('.').default; diff --git a/app/features/PeerConnection/ReceiveEncryptedMessagePayload.d.ts b/app/features/PeerConnection/ReceiveEncryptedMessagePayload.d.ts new file mode 100644 index 0000000..5947317 --- /dev/null +++ b/app/features/PeerConnection/ReceiveEncryptedMessagePayload.d.ts @@ -0,0 +1,6 @@ +interface ReceiveEncryptedMessagePayload { + payload: string; + signature: string; + iv: string; + keys: { sessionKey: string; signingKey: string }[]; +} diff --git a/app/features/PeerConnection/SendEncryptedMessagePayload.d.ts b/app/features/PeerConnection/SendEncryptedMessagePayload.d.ts new file mode 100644 index 0000000..009d0f5 --- /dev/null +++ b/app/features/PeerConnection/SendEncryptedMessagePayload.d.ts @@ -0,0 +1,4 @@ +interface SendEncryptedMessagePayload { + type: string; + payload: Record; +} diff --git a/app/features/PeerConnection/createDesktopCapturerStream.spec.ts b/app/features/PeerConnection/createDesktopCapturerStream.spec.ts new file mode 100644 index 0000000..5177d8a --- /dev/null +++ b/app/features/PeerConnection/createDesktopCapturerStream.spec.ts @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import DesktopCapturerSourceType from '../DesktopCapturerSourcesService/DesktopCapturerSourceType'; +import createDesktopCapturerStream from './createDesktopCapturerStream'; +import getDesktopSourceStreamBySourceID from './getDesktopSourceStreamBySourceID'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); +jest.mock('./getDesktopSourceStreamBySourceID', () => { + return jest.fn(); +}); + +const MOCK_MEDIA_STREAM = ({} as unknown) as MediaStream; +const TEST_SCREEN_SOURCE_ID = 'screen:1234fa'; +const TEST_WINDOW_SOURCE_ID = 'window:1234fa'; +const TEST_DISPLAY_SIZE = { width: 640, height: 480 }; + +describe('createDesktopCapturerStream callback', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + // @ts-ignore + getDesktopSourceStreamBySourceID.mockReturnValueOnce(MOCK_MEDIA_STREAM); + process.env.RUN_MODE = 'dev'; + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + peerConnection.desktopCapturerSourceID = DesktopCapturerSourceType.SCREEN; + }); + + afterEach(() => { + process.env.RUN_MODE = 'test'; + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when createDesktopCapturerStream called properly', () => { + describe('when source type is screen', () => { + it('should call getDesktopSourceStreamBySourceID with proper parameters and set localStream', async () => { + peerConnection.sourceDisplaySize = { width: 640, height: 480 }; + + await createDesktopCapturerStream( + peerConnection, + TEST_SCREEN_SOURCE_ID + ); + + expect(getDesktopSourceStreamBySourceID).toBeCalledWith( + TEST_SCREEN_SOURCE_ID, + TEST_DISPLAY_SIZE.width, + TEST_DISPLAY_SIZE.height, + 0.5, + 1 + ); + + expect(peerConnection.localStream).toBe(MOCK_MEDIA_STREAM); + }); + }); + + describe('when source type is window', () => { + it('should call getDesktopSourceStreamBySourceID with proper parameters and set localStream', async () => { + await createDesktopCapturerStream( + peerConnection, + TEST_WINDOW_SOURCE_ID + ); + + expect(getDesktopSourceStreamBySourceID).toBeCalledWith( + TEST_WINDOW_SOURCE_ID + ); + + expect(peerConnection.localStream).toBe(MOCK_MEDIA_STREAM); + }); + }); + }); +}); diff --git a/app/features/PeerConnection/createDesktopCapturerStream.ts b/app/features/PeerConnection/createDesktopCapturerStream.ts new file mode 100644 index 0000000..796dd3f --- /dev/null +++ b/app/features/PeerConnection/createDesktopCapturerStream.ts @@ -0,0 +1,32 @@ +/* eslint-disable promise/catch-or-return */ +import getDesktopSourceStreamBySourceID from './getDesktopSourceStreamBySourceID'; +import Logger from '../../utils/LoggerWithFilePrefix'; +import DesktopCapturerSourceType from '../DesktopCapturerSourcesService/DesktopCapturerSourceType'; + +const log = new Logger(__filename); + +export default async function createDesktopCapturerStream( + peerConnection: PeerConnection, + sourceID: string +) { + try { + if (process.env.RUN_MODE === 'test') return; + + if (sourceID.includes(DesktopCapturerSourceType.SCREEN)) { + const stream = await getDesktopSourceStreamBySourceID( + sourceID, + peerConnection.sourceDisplaySize?.width, + peerConnection.sourceDisplaySize?.height, + 0.5, + 1 + ); + peerConnection.localStream = stream; + } else { + // when souce is app window + const stream = await getDesktopSourceStreamBySourceID(sourceID); + peerConnection.localStream = stream; + } + } catch (e) { + log.error(e); + } +} diff --git a/app/features/PeerConnection/getDesktopSourceStreamBySourceID.spec.ts b/app/features/PeerConnection/getDesktopSourceStreamBySourceID.spec.ts new file mode 100644 index 0000000..47c9d94 --- /dev/null +++ b/app/features/PeerConnection/getDesktopSourceStreamBySourceID.spec.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import getDesktopSourceStreamBySourceID from './getDesktopSourceStreamBySourceID'; + +jest.useFakeTimers(); + +const TEST_SCREEN_SHARING_SOURCE_ID = 'screen:1234ad'; + +describe('getDesktopSourceStreamBySourceID callback', () => { + beforeEach(() => { + // eslint-disable-next-line no-global-assign + // @ts-ignore + global.navigator.mediaDevices = { getUserMedia: jest.fn() }; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when getDesktopSourceStreamBySourceID called with default parameters', () => { + it('should handle getUserMedia without width and height (APPLICATION WINDOW SHARING CASE)', () => { + getDesktopSourceStreamBySourceID(TEST_SCREEN_SHARING_SOURCE_ID); + + expect(navigator.mediaDevices.getUserMedia).toBeCalledWith({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: TEST_SCREEN_SHARING_SOURCE_ID, + minFrameRate: 15, + maxFrameRate: 60, + }, + }, + }); + }); + }); + + describe('when getDesktopSourceStreamBySourceID called with width and height parameters (SCREEN SHARING CASE)', () => { + it('should handle getUserMedia with width and height', () => { + const TEST_WIDTH = 640; + const TEST_HEIGHT = 480; + getDesktopSourceStreamBySourceID(TEST_SCREEN_SHARING_SOURCE_ID, 640, 480); + + expect(navigator.mediaDevices.getUserMedia).toBeCalledWith({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: TEST_SCREEN_SHARING_SOURCE_ID, + + minWidth: TEST_WIDTH, + maxWidth: TEST_WIDTH, + minHeight: TEST_HEIGHT, + maxHeight: TEST_HEIGHT, + + minFrameRate: 15, + maxFrameRate: 60, + }, + }, + }); + }); + }); +}); diff --git a/app/features/PeerConnection/handleCreatePeer.spec.ts b/app/features/PeerConnection/handleCreatePeer.spec.ts new file mode 100644 index 0000000..29e8815 --- /dev/null +++ b/app/features/PeerConnection/handleCreatePeer.spec.ts @@ -0,0 +1,147 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import handleCreatePeer from './handleCreatePeer'; +import createDesktopCapturerStream from './createDesktopCapturerStream'; +import NullSimplePeer from './NullSimplePeer'; +import handlePeerOnData from './handlePeerOnData'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); +jest.mock('./createDesktopCapturerStream', () => { + return jest.fn(); +}); +jest.mock('./handlePeerOnData'); + +const TEST_MOCK_LOCAL_STREAM = ({} as unknown) as MediaStream; + +function initPeerWithListeners(peerConnection: PeerConnection) { + const listeners: any = {}; + peerConnection.peer = ({ + on: (eventName: string, callback: (p: any) => void) => { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + emit: (eventName: string, param: any) => { + if (listeners[eventName]) { + listeners[eventName].forEach((callback: (p: any) => void) => { + callback(param); + }); + } + }, + } as unknown) as typeof NullSimplePeer; +} + +describe('handleCreatePeer callback', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + // @ts-ignore + createDesktopCapturerStream.mockImplementation(() => { + return new Promise((resolve) => resolve(TEST_MOCK_LOCAL_STREAM)); + }); + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when handleCreatePeer called properly', () => { + it('should call createDesktopCapturerStream', async () => { + await handleCreatePeer(peerConnection); + + expect(createDesktopCapturerStream).toBeCalled(); + }); + + it('should make .peer defined', async () => { + await handleCreatePeer(peerConnection); + + expect(peerConnection.peer).not.toEqual(NullSimplePeer); + }); + + it('should add localStream to peer with addStream', async () => { + peerConnection.localStream = TEST_MOCK_LOCAL_STREAM; + + await handleCreatePeer(peerConnection); + + expect(peerConnection.peer.addStream).toBeCalledWith( + TEST_MOCK_LOCAL_STREAM + ); + }); + + it('should set .peer.on(signal event listner', async () => { + await handleCreatePeer(peerConnection); + + expect(peerConnection.peer.on).toBeCalledWith( + 'signal', + expect.anything() + ); + }); + + it('should set .peer.on(data event listner', async () => { + await handleCreatePeer(peerConnection); + + expect(peerConnection.peer.on).toBeCalledWith('data', expect.anything()); + }); + + it('should resolve with undefined', async () => { + const res = await handleCreatePeer(peerConnection); + + expect(res).toBe(undefined); + }); + + describe('when peer on "signal" even occured', () => { + it('should add signal to .signalsDataToCallUser', async () => { + const TEST_SIGNAL_DATA = '1234'; + initPeerWithListeners(peerConnection); + + await handleCreatePeer(peerConnection); + + peerConnection.peer.emit('signal', TEST_SIGNAL_DATA); + + expect(peerConnection.signalsDataToCallUser).toEqual([ + TEST_SIGNAL_DATA, + ]); + }); + }); + + describe('when peer on "data" even occured', () => { + it('should add signal to .signalsDataToCallUser', async () => { + const TEST_DATA = 'asdfasdfasdf'; + initPeerWithListeners(peerConnection); + + await handleCreatePeer(peerConnection); + + peerConnection.peer.emit('data', TEST_DATA); + + expect(handlePeerOnData).toBeCalled(); + }); + }); + }); +}); diff --git a/app/features/PeerConnection/handleCreatePeer.ts b/app/features/PeerConnection/handleCreatePeer.ts new file mode 100644 index 0000000..19e7fcd --- /dev/null +++ b/app/features/PeerConnection/handleCreatePeer.ts @@ -0,0 +1,47 @@ +import SimplePeer from 'simple-peer'; +import createDesktopCapturerStream from './createDesktopCapturerStream'; +import handlePeerOnData from './handlePeerOnData'; +// import setSdpMediaBitrate from './setSdpMediaBitrate'; +import Logger from '../../utils/LoggerWithFilePrefix'; +import NullSimplePeer from './NullSimplePeer'; +import simplePeerHandleSdpTransform from './simplePeerHandleSdpTransform'; + +const log = new Logger(__filename); + +export default function handleCreatePeer(peerConnection: PeerConnection) { + return new Promise((resolve, reject) => { + createDesktopCapturerStream( + peerConnection, + peerConnection.desktopCapturerSourceID + ) + .then(() => { + // eslint-disable-next-line promise/always-return + if (peerConnection.peer === NullSimplePeer) { + peerConnection.peer = new SimplePeer({ + initiator: true, + config: { iceServers: [] }, + sdpTransform: simplePeerHandleSdpTransform, + }); + } + + // eslint-disable-next-line promise/always-return + if (peerConnection.localStream !== null) { + peerConnection.peer.addStream(peerConnection.localStream); + } + + peerConnection.peer.on('signal', (data: string) => { + // fired when simple peer and webrtc done preparation to start call on peerConnection machine + peerConnection.signalsDataToCallUser.push(data); + }); + + peerConnection.peer.on('data', (data) => { + handlePeerOnData(peerConnection, data); + }); + resolve(undefined); + }) + .catch((e) => { + log.error(e); + reject(); + }); + }); +} diff --git a/app/features/PeerConnection/handlePeerOnData.spec.ts b/app/features/PeerConnection/handlePeerOnData.spec.ts new file mode 100644 index 0000000..d72c205 --- /dev/null +++ b/app/features/PeerConnection/handlePeerOnData.spec.ts @@ -0,0 +1,208 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import handlePeerOnData from './handlePeerOnData'; +import getDesktopSourceStreamBySourceID from './getDesktopSourceStreamBySourceID'; + +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import DesktopCapturerSourceType from '../DesktopCapturerSourcesService/DesktopCapturerSourceType'; +import NullSimplePeer from './NullSimplePeer'; +import prepareDataMessageToSendScreenSourceType from './prepareDataMessageToSendScreenSourceType'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); +jest.mock('./getDesktopSourceStreamBySourceID', () => { + return jest.fn(); +}); + +const TEST_DATA_SET_VIDEO_QUALITY_05 = ` +{ + "type": "set_video_quality", + "payload": { + "value": 0.5 + } +} +`; + +const TEST_DATA_GET_SHARING_SOURCE_TYPE = ` +{ + "type": "get_sharing_source_type", + "payload": { + } +} +`; + +describe('handlePeerOnData callback', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + peerConnection.desktopCapturerSourceID = DesktopCapturerSourceType.SCREEN; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when handlePeerOnData called properly', () => { + describe('when handlePeerOnData called with set_video_quality data and when sharing source is SCREEN', () => { + it('should create new stream', () => { + handlePeerOnData(peerConnection, TEST_DATA_SET_VIDEO_QUALITY_05); + + expect(getDesktopSourceStreamBySourceID).toBeCalled(); + }); + + it('should call replaceTrack() on peer', async () => { + // @ts-ignore + getDesktopSourceStreamBySourceID.mockImplementation( + () => + (({ + getVideoTracks: () => [{ stop: jest.fn() }], + } as unknown) as MediaStream) + ); + peerConnection.localStream = ({ + getVideoTracks: () => [{ stop: jest.fn() }], + } as unknown) as MediaStream; + peerConnection.peer = ({ + replaceTrack: jest.fn(), + } as unknown) as typeof NullSimplePeer; + await handlePeerOnData(peerConnection, TEST_DATA_SET_VIDEO_QUALITY_05); + + expect(peerConnection.peer.replaceTrack).toBeCalled(); + }); + + it('should call .stop() on old track to clear memory', async () => { + // @ts-ignore + getDesktopSourceStreamBySourceID.mockImplementation( + () => + (({ + getVideoTracks: () => [{ stop: jest.fn() }], + } as unknown) as MediaStream) + ); + const oldTrackStopFunctionMock = jest.fn(); + peerConnection.localStream = ({ + getVideoTracks: () => [{ stop: oldTrackStopFunctionMock }], + } as unknown) as MediaStream; + peerConnection.peer = ({ + replaceTrack: jest.fn(), + } as unknown) as typeof NullSimplePeer; + + await handlePeerOnData(peerConnection, TEST_DATA_SET_VIDEO_QUALITY_05); + + expect(oldTrackStopFunctionMock).toBeCalled(); + }); + }); + + describe('when handlePeerOnData called with set_video_quality data and when sharing source is WINDOW', () => { + it('should NOT create new stream', () => { + peerConnection.desktopCapturerSourceID = + DesktopCapturerSourceType.WINDOW; + handlePeerOnData(peerConnection, TEST_DATA_SET_VIDEO_QUALITY_05); + + expect(getDesktopSourceStreamBySourceID).not.toBeCalled(); + }); + + it('should NOT call replaceTrack() on peer', async () => { + peerConnection.desktopCapturerSourceID = + DesktopCapturerSourceType.WINDOW; + // @ts-ignore + getDesktopSourceStreamBySourceID.mockImplementation( + () => + (({ + getVideoTracks: () => [{ stop: jest.fn() }], + } as unknown) as MediaStream) + ); + peerConnection.localStream = ({ + getVideoTracks: () => [{ stop: jest.fn() }], + } as unknown) as MediaStream; + peerConnection.peer = ({ + replaceTrack: jest.fn(), + } as unknown) as typeof NullSimplePeer; + + await handlePeerOnData(peerConnection, TEST_DATA_SET_VIDEO_QUALITY_05); + + expect(peerConnection.peer.replaceTrack).not.toBeCalled(); + }); + + it('should NOT call .stop() on old track to clear memory', async () => { + peerConnection.desktopCapturerSourceID = + DesktopCapturerSourceType.WINDOW; + // @ts-ignore + getDesktopSourceStreamBySourceID.mockImplementation( + () => + (({ + getVideoTracks: () => [{ stop: jest.fn() }], + } as unknown) as MediaStream) + ); + const oldTrackStopFunctionMock = jest.fn(); + peerConnection.localStream = ({ + getVideoTracks: () => [{ stop: oldTrackStopFunctionMock }], + } as unknown) as MediaStream; + peerConnection.peer = ({ + replaceTrack: jest.fn(), + } as unknown) as typeof NullSimplePeer; + + await handlePeerOnData(peerConnection, TEST_DATA_SET_VIDEO_QUALITY_05); + + expect(oldTrackStopFunctionMock).not.toBeCalled(); + }); + }); + + describe('when handlePeerOnData called with get_sharing_source_type data', () => { + describe('when sharing source type is SCREEN', () => { + it('should call peer.send() with proper data', () => { + peerConnection.peer = ({ + send: jest.fn(), + } as unknown) as typeof NullSimplePeer; + + handlePeerOnData(peerConnection, TEST_DATA_GET_SHARING_SOURCE_TYPE); + + expect(peerConnection.peer.send).toBeCalledWith( + prepareDataMessageToSendScreenSourceType( + DesktopCapturerSourceType.SCREEN + ) + ); + }); + }); + + describe('when sharing source type is WINDOW', () => { + it('should call peer.send() with proper data', () => { + peerConnection.desktopCapturerSourceID = + DesktopCapturerSourceType.WINDOW; + peerConnection.peer = ({ + send: jest.fn(), + } as unknown) as typeof NullSimplePeer; + + handlePeerOnData(peerConnection, TEST_DATA_GET_SHARING_SOURCE_TYPE); + + expect(peerConnection.peer.send).toBeCalledWith( + prepareDataMessageToSendScreenSourceType( + DesktopCapturerSourceType.WINDOW + ) + ); + }); + }); + }); + }); +}); diff --git a/app/features/PeerConnection/handlePeerOnData.ts b/app/features/PeerConnection/handlePeerOnData.ts new file mode 100644 index 0000000..ad1278f --- /dev/null +++ b/app/features/PeerConnection/handlePeerOnData.ts @@ -0,0 +1,54 @@ +import DesktopCapturerSourceType from '../DesktopCapturerSourcesService/DesktopCapturerSourceType'; +import getDesktopSourceStreamBySourceID from './getDesktopSourceStreamBySourceID'; +import prepareDataMessageToSendScreenSourceType from './prepareDataMessageToSendScreenSourceType'; + +export default async function handlePeerOnData( + peerConnection: PeerConnection, + data: string +) { + const dataJSON = JSON.parse(data); + + if (dataJSON.type === 'set_video_quality') { + const maxVideoQualityMultiplier = dataJSON.payload.value; + const minVideoQualityMultiplier = + maxVideoQualityMultiplier === 1 ? 0.5 : maxVideoQualityMultiplier; + + if ( + !peerConnection.desktopCapturerSourceID.includes( + DesktopCapturerSourceType.SCREEN + ) + ) + return; + + const newStream = await getDesktopSourceStreamBySourceID( + peerConnection.desktopCapturerSourceID, + peerConnection.sourceDisplaySize?.width, + peerConnection.sourceDisplaySize?.height, + minVideoQualityMultiplier, + maxVideoQualityMultiplier + ); + const newVideoTrack = newStream.getVideoTracks()[0]; + const oldTrack = peerConnection.localStream?.getVideoTracks()[0]; + + if (oldTrack && peerConnection.localStream) { + peerConnection.peer.replaceTrack( + oldTrack, + newVideoTrack, + peerConnection.localStream + ); + oldTrack.stop(); + } + } + + if (dataJSON.type === 'get_sharing_source_type') { + const sourceType = peerConnection.desktopCapturerSourceID.includes( + DesktopCapturerSourceType.SCREEN + ) + ? DesktopCapturerSourceType.SCREEN + : DesktopCapturerSourceType.WINDOW; + + peerConnection.peer.send( + prepareDataMessageToSendScreenSourceType(sourceType) + ); + } +} diff --git a/app/features/PeerConnection/handleRecieveEncryptedMessage.spec.ts b/app/features/PeerConnection/handleRecieveEncryptedMessage.spec.ts new file mode 100644 index 0000000..5c6a578 --- /dev/null +++ b/app/features/PeerConnection/handleRecieveEncryptedMessage.spec.ts @@ -0,0 +1,199 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import uuid from 'uuid'; +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import { process as processMessage } from '../../utils/message'; +import NullSimplePeer from './NullSimplePeer'; +import handleRecieveEncryptedMessage, { + handleDeviceIPMessage, +} from './handleRecieveEncryptedMessage'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); +jest.mock('../../utils/message', () => { + return { process: jest.fn() }; +}); +jest.mock('uuid', () => { + return { + v4: () => '1234kdkd', + }; +}); + +const TEST_DEVICE_DETAILS_PAYLOAD = { + socketID: '123', + deviceType: 'computer', + os: 'Windows', + browser: 'Chrome 72', + deviceScreenWidth: 640, + deviceScreenHeight: 480, +}; + +const TEST_DUMMY_ENCRYPTED_MESSAGE_PAYLOAD = ({} as unknown) as ReceiveEncryptedMessagePayload; + +describe('handleRecieveEncryptedMessage.ts', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when handleRecieveEncryptedMessage called properly', () => { + describe('when processed message type is CALL_ACCEPTED', () => { + it('should call peer.signal() with proper signal data', async () => { + const TEST_SIGNAL_DATA = 'a32sdlf'; + // @ts-ignore + processMessage.mockImplementation(() => { + return { + type: 'CALL_ACCEPTED', + payload: { + signalData: TEST_SIGNAL_DATA, + }, + }; + }); + peerConnection.peer = ({ + signal: jest.fn(), + } as unknown) as typeof NullSimplePeer; + + await handleRecieveEncryptedMessage( + peerConnection, + TEST_DUMMY_ENCRYPTED_MESSAGE_PAYLOAD + ); + + expect(peerConnection.peer.signal).toBeCalledWith(TEST_SIGNAL_DATA); + }); + }); + + describe('when processed message type is DEVICE_DETAILS', () => { + it('should call socket.emit() to get partner device IP', async () => { + peerConnection.socket = ({ + emit: jest.fn(), + } as unknown) as SocketIOClient.Socket; + + // @ts-ignore + processMessage.mockImplementation(() => { + return { + type: 'DEVICE_DETAILS', + payload: TEST_DEVICE_DETAILS_PAYLOAD, + }; + }); + + await handleRecieveEncryptedMessage( + peerConnection, + TEST_DUMMY_ENCRYPTED_MESSAGE_PAYLOAD + ); + + expect(peerConnection.socket.emit).toBeCalledWith( + 'GET_IP_BY_SOCKET_ID', + expect.anything(), + expect.anything() + ); + }); + }); + + describe('when processed message type is GET_APP_THEME', () => { + it('should call .sendEncryptedMessage with proper payload', async () => { + peerConnection.sendEncryptedMessage = jest.fn(); + const TEST_GET_APP_THEME_PAYLOAD = { + type: 'APP_THEME', + payload: { value: peerConnection.appColorTheme }, + }; + // @ts-ignore + processMessage.mockImplementation(() => { + return { + type: 'GET_APP_THEME', + payload: {}, + }; + }); + + await handleRecieveEncryptedMessage( + peerConnection, + TEST_DUMMY_ENCRYPTED_MESSAGE_PAYLOAD + ); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith( + TEST_GET_APP_THEME_PAYLOAD + ); + }); + }); + + describe('when processed message type is GET_APP_LANGUAGE', () => { + it('should call .sendEncryptedMessage with proper payload', async () => { + peerConnection.sendEncryptedMessage = jest.fn(); + const TEST_GET_APP_LANGUAGE_PAYLOAD = { + type: 'APP_LANGUAGE', + payload: { value: peerConnection.appLanguage }, + }; + // @ts-ignore + processMessage.mockImplementation(() => { + return { + type: 'GET_APP_LANGUAGE', + payload: {}, + }; + }); + + await handleRecieveEncryptedMessage( + peerConnection, + TEST_DUMMY_ENCRYPTED_MESSAGE_PAYLOAD + ); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith( + TEST_GET_APP_LANGUAGE_PAYLOAD + ); + }); + }); + }); + + describe('when handleDeviceIPMessage was called properly', () => { + it('should set partnerDeviceDetails with message payload and call device connected callback', async () => { + const TEST_DEVICE_IP = '123.123.123.123'; + const TEST_DEVICE_TO_BE_SET = { + deviceIP: TEST_DEVICE_IP, + deviceType: TEST_DEVICE_DETAILS_PAYLOAD.deviceType, + deviceOS: TEST_DEVICE_DETAILS_PAYLOAD.os, + deviceBrowser: TEST_DEVICE_DETAILS_PAYLOAD.browser, + deviceScreenWidth: TEST_DEVICE_DETAILS_PAYLOAD.deviceScreenWidth, + deviceScreenHeight: TEST_DEVICE_DETAILS_PAYLOAD.deviceScreenHeight, + sharingSessionID: peerConnection.sharingSessionID, + id: uuid.v4(), + }; + peerConnection.onDeviceConnectedCallback = jest.fn(); + handleDeviceIPMessage(TEST_DEVICE_IP, peerConnection, { + type: 'DEVICE_DETAILS', + payload: TEST_DEVICE_DETAILS_PAYLOAD, + }); + + expect(peerConnection.partnerDeviceDetails).toEqual( + TEST_DEVICE_TO_BE_SET + ); + expect(peerConnection.onDeviceConnectedCallback).toBeCalledWith( + TEST_DEVICE_TO_BE_SET + ); + }); + }); +}); diff --git a/app/features/PeerConnection/handleRecieveEncryptedMessage.ts b/app/features/PeerConnection/handleRecieveEncryptedMessage.ts new file mode 100644 index 0000000..510bcb4 --- /dev/null +++ b/app/features/PeerConnection/handleRecieveEncryptedMessage.ts @@ -0,0 +1,53 @@ +import uuid from 'uuid'; +import { process as processMessage } from '../../utils/message'; + +export function handleDeviceIPMessage( + deviceIP: string, + peerConnection: PeerConnection, + message: ProcessedMessage +) { + if (message.type !== 'DEVICE_DETAILS') return; + const device = { + id: uuid.v4(), + deviceIP, + deviceType: message.payload.deviceType, + deviceOS: message.payload.os, + deviceBrowser: message.payload.browser, + deviceScreenWidth: message.payload.deviceScreenWidth, + deviceScreenHeight: message.payload.deviceScreenHeight, + sharingSessionID: peerConnection.sharingSessionID, + }; + peerConnection.partnerDeviceDetails = device; + peerConnection.onDeviceConnectedCallback(device); +} + +export default async function handleRecieveEncryptedMessage( + peerConnection: PeerConnection, + payload: ReceiveEncryptedMessagePayload +) { + const message = await processMessage(payload, peerConnection.user.privateKey); + if (message.type === 'CALL_ACCEPTED') { + peerConnection.peer.signal(message.payload.signalData); + } + if (message.type === 'DEVICE_DETAILS') { + peerConnection.socket.emit( + 'GET_IP_BY_SOCKET_ID', + message.payload.socketID, + (deviceIP: string) => { + handleDeviceIPMessage(deviceIP, peerConnection, message); + } + ); + } + if (message.type === 'GET_APP_THEME') { + peerConnection.sendEncryptedMessage({ + type: 'APP_THEME', + payload: { value: peerConnection.appColorTheme }, + }); + } + if (message.type === 'GET_APP_LANGUAGE') { + peerConnection.sendEncryptedMessage({ + type: 'APP_LANGUAGE', + payload: { value: peerConnection.appLanguage }, + }); + } +} diff --git a/app/features/PeerConnection/handleSelfDestroy.spec.ts b/app/features/PeerConnection/handleSelfDestroy.spec.ts new file mode 100644 index 0000000..1628fc1 --- /dev/null +++ b/app/features/PeerConnection/handleSelfDestroy.spec.ts @@ -0,0 +1,136 @@ +import handleSelfDestroy from './handleSelfDestroy'; +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import NullSimplePeer from './NullSimplePeer'; +import SharingSession from '../SharingSessionService/SharingSession'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); + +const TEST_PARTNER = { + username: 'asdfaf', + publicKey: 'afafdsg', +}; + +const TEST_PARTNER_DEVICE_ID = '123fdsad'; +const TEST_SHARING_SESSION = ({ + destroy: jest.fn(), + setStatus: jest.fn(), +} as unknown) as SharingSession; + +describe('handleSelfDestroy callback', () => { + // let sharingSessionService; + let peerConnection: PeerConnection; + + beforeEach(() => { + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + ({ + unmarkRoomIDAsTaken: jest.fn(), + } as unknown) as RoomIDService, + ({ + removeDeviceByID: jest.fn(), + } as unknown) as ConnectedDevicesService, + ({ + sharingSessions: { + get: () => TEST_SHARING_SESSION, + delete: jest.fn(), + }, + } as unknown) as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when handleSelfDestroy callback called properly', () => { + it('should set peerConnection to other than it was', () => { + peerConnection.partner = TEST_PARTNER; + + handleSelfDestroy(peerConnection); + + expect(peerConnection.partner).not.toEqual(TEST_PARTNER); + }); + + it('should remove device from connectedDevicesService device id', () => { + peerConnection.partnerDeviceDetails.id = TEST_PARTNER_DEVICE_ID; + + handleSelfDestroy(peerConnection); + + expect( + peerConnection.connectedDevicesService.removeDeviceByID + ).toBeCalledWith(TEST_PARTNER_DEVICE_ID); + }); + + it('should call .destroy() on simple peer', () => { + peerConnection.peer = ({ + destroy: jest.fn(), + } as unknown) as typeof NullSimplePeer; + + handleSelfDestroy(peerConnection); + + expect(peerConnection.peer.destroy).toBeCalled(); + }); + + it('should stop all localStream tracks and set it to null', () => { + const testTrack1 = { + stop: jest.fn(), + }; + const testTrack2 = { + stop: jest.fn(), + }; + const TEST_LOCAL_STREAM = ({ + getTracks: () => [testTrack1, testTrack2], + } as unknown) as MediaStream; + peerConnection.localStream = TEST_LOCAL_STREAM; + + handleSelfDestroy(peerConnection); + + expect(testTrack1.stop).toBeCalled(); + expect(testTrack2.stop).toBeCalled(); + expect(peerConnection.localStream).toBeNull(); + }); + + it('should call sharingSession .destroy()', () => { + handleSelfDestroy(peerConnection); + + expect(TEST_SHARING_SESSION.destroy).toBeCalled(); + }); + + it('should delete sharing session from sharing session service', () => { + handleSelfDestroy(peerConnection); + + expect( + peerConnection.sharingSessionService.sharingSessions.delete + ).toBeCalledWith(peerConnection.sharingSessionID); + }); + + it('should disconnect socket server', () => { + peerConnection.socket = ({ + disconnect: jest.fn(), + } as unknown) as SocketIOClient.Socket; + + handleSelfDestroy(peerConnection); + + expect(peerConnection.socket.disconnect).toBeCalled(); + }); + }); +}); diff --git a/app/features/PeerConnection/handleSelfDestroy.ts b/app/features/PeerConnection/handleSelfDestroy.ts new file mode 100644 index 0000000..1803e55 --- /dev/null +++ b/app/features/PeerConnection/handleSelfDestroy.ts @@ -0,0 +1,31 @@ +import SharingSessionStatusEnum from '../SharingSessionService/SharingSessionStatusEnum'; +import NullSimplePeer from './NullSimplePeer'; +import NullUser from './NullUser'; + +export default function handleSelfDestroy(peerConnection: PeerConnection) { + peerConnection.partner = NullUser; + peerConnection.connectedDevicesService.removeDeviceByID( + peerConnection.partnerDeviceDetails.id + ); + if (peerConnection.peer !== NullSimplePeer) { + peerConnection.peer.destroy(); + } + if (peerConnection.localStream) { + peerConnection.localStream.getTracks().forEach((track) => { + track.stop(); + }); + peerConnection.localStream = null; + } + const sharingSession = peerConnection.sharingSessionService.sharingSessions.get( + peerConnection.sharingSessionID + ); + sharingSession?.setStatus(SharingSessionStatusEnum.DESTROYED); + sharingSession?.destroy(); + peerConnection.sharingSessionService.sharingSessions.delete( + peerConnection.sharingSessionID + ); + peerConnection.onDeviceConnectedCallback = () => {}; + peerConnection.isCallStarted = false; + peerConnection.socket.disconnect(); + peerConnection.roomIDService.unmarkRoomIDAsTaken(peerConnection.roomID); +} diff --git a/app/features/PeerConnection/handleSetDisplaySizeFromLocalStream.spec.ts b/app/features/PeerConnection/handleSetDisplaySizeFromLocalStream.spec.ts new file mode 100644 index 0000000..bedf251 --- /dev/null +++ b/app/features/PeerConnection/handleSetDisplaySizeFromLocalStream.spec.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import setDisplaySizeFromLocalStream from './handleSetDisplaySizeFromLocalStream'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); + +const TEST_MOCK_DISPLAY_SIZE = { + width: 1280, + height: 640, +}; + +describe('setDisplaySizeFromLocalStream callback', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + peerConnection.localStream = ({ + getVideoTracks: () => [ + { + getSettings: () => { + return TEST_MOCK_DISPLAY_SIZE; + }, + }, + ], + } as unknown) as MediaStream; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when setDisplaySizeFromLocalStream called properly', () => { + it('should set width and height on .sourceDisplaySize', () => { + setDisplaySizeFromLocalStream(peerConnection); + + expect(peerConnection.sourceDisplaySize).toEqual(TEST_MOCK_DISPLAY_SIZE); + }); + }); + + describe('when setDisplaySizeFromLocalStream was NOT called properly', () => { + describe('when localStream is null', () => { + it('should have .sourceDisplaySize as undefined', () => { + peerConnection.localStream = null; + + setDisplaySizeFromLocalStream(peerConnection); + + expect(peerConnection.sourceDisplaySize).toBe(undefined); + }); + }); + + describe('when peerConnection.localStream.getVideoTracks()[0].getSettings() width or height is undefined', () => { + it('should have .sourceDisplaySize to be undefined', () => { + peerConnection.localStream = ({ + getVideoTracks: () => [ + { + getSettings: () => { + return { + width: undefined, + height: undefined, + }; + }, + }, + ], + } as unknown) as MediaStream; + + setDisplaySizeFromLocalStream(peerConnection); + + expect(peerConnection.sourceDisplaySize).toBe(undefined); + }); + }); + }); +}); diff --git a/app/features/PeerConnection/handleSetDisplaySizeFromLocalStream.ts b/app/features/PeerConnection/handleSetDisplaySizeFromLocalStream.ts new file mode 100644 index 0000000..66d2c2d --- /dev/null +++ b/app/features/PeerConnection/handleSetDisplaySizeFromLocalStream.ts @@ -0,0 +1,23 @@ +export default function setDisplaySizeFromLocalStream( + peerConnection: PeerConnection +) { + if ( + !peerConnection.localStream || + !peerConnection.localStream.getVideoTracks()[0] + ) + return; + if (!peerConnection.localStream.getVideoTracks()[0].getSettings().width) + return; + if (!peerConnection.localStream.getVideoTracks()[0].getSettings().height) + return; + peerConnection.sourceDisplaySize = { + width: peerConnection.localStream.getVideoTracks()[0].getSettings().width + ? (peerConnection.localStream.getVideoTracks()[0].getSettings() + .width as number) + : 640, + height: peerConnection.localStream.getVideoTracks()[0].getSettings().height + ? (peerConnection.localStream.getVideoTracks()[0].getSettings() + .height as number) + : 480, + }; +} diff --git a/app/features/PeerConnection/handleSocket.spec.ts b/app/features/PeerConnection/handleSocket.spec.ts new file mode 100644 index 0000000..d03cfe5 --- /dev/null +++ b/app/features/PeerConnection/handleSocket.spec.ts @@ -0,0 +1,199 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import handleSocket from './handleSocket'; +import handleSocketUserEnter from './handleSocketUserEnter'; +import handleSocketUserExit from './handleSocketUserExit'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); +jest.mock('./handleSocketUserEnter'); +jest.mock('./handleSocketUserExit'); + +function initSocketWithListeners(peerConnection: PeerConnection) { + const listeners: any = {}; + peerConnection.socket = ({ + on: (eventName: string, callback: (p: any) => void) => { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + emit: (eventName: string, param: any) => { + if (listeners[eventName]) { + listeners[eventName].forEach((callback: (p: any) => void) => { + callback(param); + }); + } + }, + removeAllListeners: () => {}, + } as unknown) as SocketIOClient.Socket; +} + +describe('handleSocket callback', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + // @ts-ignore + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + peerConnection.socket = ({ + on: jest.fn(), + removeAllListeners: jest.fn(), + } as unknown) as SocketIOClient.Socket; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when handleSocket called properly', () => { + it('should call removeAllListeners', () => { + handleSocket(peerConnection); + + expect(peerConnection.socket.removeAllListeners).toBeCalled(); + }); + + it('should call socket.on(connect', () => { + handleSocket(peerConnection); + + expect(peerConnection.socket.on).toBeCalledWith( + 'connect', + expect.anything() + ); + }); + + it('should call socket.on(disconnect', () => { + handleSocket(peerConnection); + + expect(peerConnection.socket.on).toBeCalledWith( + 'disconnect', + expect.anything() + ); + }); + + it('should call socket.on(USER_ENTER', () => { + handleSocket(peerConnection); + + expect(peerConnection.socket.on).toBeCalledWith( + 'USER_ENTER', + expect.anything() + ); + }); + + it('should call socket.on(USER_EXIT', () => { + handleSocket(peerConnection); + + expect(peerConnection.socket.on).toBeCalledWith( + 'USER_EXIT', + expect.anything() + ); + }); + + it('should call socket.on(ENCRYPTED_MESSAGE', () => { + handleSocket(peerConnection); + + expect(peerConnection.socket.on).toBeCalledWith( + 'ENCRYPTED_MESSAGE', + expect.anything() + ); + }); + + it('should call socket.on(USER_DISCONNECT', () => { + handleSocket(peerConnection); + + expect(peerConnection.socket.on).toBeCalledWith( + 'USER_DISCONNECT', + expect.anything() + ); + }); + + describe('when ENCRYPTED_MESSAGE event occured', () => { + it('should call receiveEncryptedMessage on peer connection object with proper payload', () => { + peerConnection.receiveEncryptedMessage = jest.fn(); + const TEST_ENCRYPTED_MESSAGE_PAYLOAD = { + test: 'sfss', + }; + initSocketWithListeners(peerConnection); + + handleSocket(peerConnection); + peerConnection.socket.emit( + 'ENCRYPTED_MESSAGE', + TEST_ENCRYPTED_MESSAGE_PAYLOAD + ); + + expect(peerConnection.receiveEncryptedMessage).toBeCalledWith( + TEST_ENCRYPTED_MESSAGE_PAYLOAD + ); + }); + }); + + describe('when USER_DISCONNECT event occured', () => { + it('should call .socket.emit with TOGGLE_LOCK_ROOM event', () => { + peerConnection.toggleLockRoom = jest.fn(); + initSocketWithListeners(peerConnection); + + handleSocket(peerConnection); + peerConnection.socket.emit('USER_DISCONNECT'); + + expect(peerConnection.toggleLockRoom).toBeCalledWith(false); + }); + }); + + describe('when USER_ENTER event occured', () => { + it('should call handleSocketUserEnter callback', () => { + initSocketWithListeners(peerConnection); + + handleSocket(peerConnection); + peerConnection.socket.emit('USER_ENTER'); + + expect(handleSocketUserEnter).toBeCalled(); + }); + }); + + describe('when USER_EXIT event occured', () => { + it('should call handleSocketUserEnter callback', () => { + initSocketWithListeners(peerConnection); + + handleSocket(peerConnection); + peerConnection.socket.emit('USER_EXIT'); + + expect(handleSocketUserExit).toBeCalled(); + }); + }); + + describe('when "disconnect" event occured', () => { + it('should call .selfDestrory() callback', () => { + peerConnection.selfDestroy = jest.fn(); + initSocketWithListeners(peerConnection); + + handleSocket(peerConnection); + peerConnection.socket.emit('disconnect'); + + expect(peerConnection.selfDestroy).toBeCalled(); + }); + }); + }); +}); diff --git a/app/features/PeerConnection/handleSocket.ts b/app/features/PeerConnection/handleSocket.ts new file mode 100644 index 0000000..9b85f8c --- /dev/null +++ b/app/features/PeerConnection/handleSocket.ts @@ -0,0 +1,44 @@ +import handleSocketUserEnter from './handleSocketUserEnter'; +import handleSocketUserExit from './handleSocketUserExit'; + +export default function handleSocket(peerConnection: PeerConnection) { + peerConnection.socket.removeAllListeners(); + + peerConnection.socket.on('disconnect', () => { + peerConnection.selfDestroy(); + }); + + peerConnection.socket.on('connect', () => { + // peerConnection.emitUserEnter(); + }); + + peerConnection.socket.on( + 'USER_ENTER', + (payload: { users: PartnerPeerUser[] }) => { + handleSocketUserEnter(peerConnection, payload); + } + ); + + peerConnection.socket.on('USER_EXIT', () => { + handleSocketUserExit(peerConnection); + }); + + peerConnection.socket.on( + 'ENCRYPTED_MESSAGE', + (payload: ReceiveEncryptedMessagePayload) => { + peerConnection.receiveEncryptedMessage(payload); + } + ); + + peerConnection.socket.on('USER_DISCONNECT', () => { + peerConnection.toggleLockRoom(false); + }); + + // socketConnection.on('TOGGLE_LOCK_ROOM', payload => { + // peerConnection.props.receiveUnencryptedMessage('TOGGLE_LOCK_ROOM', payload); + // }); + + // socketConnection.on('ROOM_LOCKED', payload => { + // peerConnection.props.openModal('Room Locked'); + // }); +} diff --git a/app/features/PeerConnection/handleSocketUserEnter.spec.ts b/app/features/PeerConnection/handleSocketUserEnter.spec.ts new file mode 100644 index 0000000..6197313 --- /dev/null +++ b/app/features/PeerConnection/handleSocketUserEnter.spec.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import handleSocketUserEnter from './handleSocketUserEnter'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); + +const TEST_PARTNER_USER = { + username: 'asdfasdf', + publicKey: 'key:asdfasdffff', +}; +const TEST_PAYLOAD = { + users: [TEST_PARTNER_USER], +}; + +function initSocketWithListeners(peerConnection: PeerConnection) { + const listeners: any = {}; + peerConnection.socket = ({ + on: (eventName: string, callback: (p: any) => void) => { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + emit: (eventName: string, param: any) => { + if (listeners[eventName]) { + listeners[eventName].forEach((callback: (p: any) => void) => { + callback(param); + }); + } + }, + removeAllListeners: () => {}, + } as unknown) as SocketIOClient.Socket; +} + +describe('handleSocketUserEnter callback', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + // @ts-ignore + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + peerConnection.socket = ({ + on: jest.fn(), + removeAllListeners: jest.fn(), + } as unknown) as SocketIOClient.Socket; + initSocketWithListeners(peerConnection); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when handleSocketUserEnter called properly', () => { + it('should set .partner to partner user', () => { + handleSocketUserEnter(peerConnection, TEST_PAYLOAD); + + expect(peerConnection.partner).toBe(TEST_PARTNER_USER); + }); + + it('should set .sendEncryptedMessage with proper payload as it is an owner of room', () => { + const TEST_SEND_MESSAGE_PAYLOAD = { + type: 'ADD_USER', + payload: { + username: peerConnection.user.username, + publicKey: peerConnection.user.publicKey, + isOwner: true, + id: peerConnection.user.username, + }, + }; + peerConnection.sendEncryptedMessage = jest.fn(); + + handleSocketUserEnter(peerConnection, TEST_PAYLOAD); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith( + TEST_SEND_MESSAGE_PAYLOAD + ); + }); + + it('should call toggleLockRoom with true', () => { + peerConnection.toggleLockRoom = jest.fn(); + + handleSocketUserEnter(peerConnection, TEST_PAYLOAD); + + expect(peerConnection.toggleLockRoom).toBeCalledWith(true); + }); + + it('should call emitUserEnter with true', () => { + peerConnection.emitUserEnter = jest.fn(); + + handleSocketUserEnter(peerConnection, TEST_PAYLOAD); + + expect(peerConnection.emitUserEnter).toBeCalled(); + }); + }); +}); diff --git a/app/features/PeerConnection/handleSocketUserEnter.ts b/app/features/PeerConnection/handleSocketUserEnter.ts new file mode 100644 index 0000000..4025b83 --- /dev/null +++ b/app/features/PeerConnection/handleSocketUserEnter.ts @@ -0,0 +1,29 @@ +export default ( + peerConnection: PeerConnection, + payload: { users: PartnerPeerUser[] } +) => { + const filteredPartner = payload.users.filter((user: PartnerPeerUser) => { + return peerConnection.user.publicKey !== user.publicKey; + }); + + if (filteredPartner[0] === undefined) return; + + [peerConnection.partner] = filteredPartner; + + peerConnection.sendEncryptedMessage({ + type: 'ADD_USER', + payload: { + username: peerConnection.user.username, + publicKey: peerConnection.user.publicKey, + isOwner: true, + id: peerConnection.user.username, + }, + }); + + if (peerConnection.partner.publicKey !== '') { + // peerConnection.socket.emit('TOGGLE_LOCK_ROOM', null, () => {}); + // peerConnection.isSocketRoomLocked = true; + peerConnection.toggleLockRoom(true); + peerConnection.emitUserEnter(); + } +}; diff --git a/app/features/PeerConnection/handleSocketUserExit.spec.ts b/app/features/PeerConnection/handleSocketUserExit.spec.ts new file mode 100644 index 0000000..24ae5ea --- /dev/null +++ b/app/features/PeerConnection/handleSocketUserExit.spec.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import handleSocketUserExit from './handleSocketUserExit'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); + +describe('handleSocketUserExit callback', () => { + let peerConnection: PeerConnection; + + beforeEach(() => { + // @ts-ignore + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, + TEST_APP_LANGUAGE, + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + {} as DesktopCapturerSourcesService + ); + peerConnection.socket = ({ + on: jest.fn(), + removeAllListeners: jest.fn(), + } as unknown) as SocketIOClient.Socket; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when handleSocketUserExit called properly', () => { + it('should call toggleLockRoom and selfDestroy', () => { + peerConnection.isSocketRoomLocked = true; + peerConnection.isCallStarted = true; + peerConnection.toggleLockRoom = jest.fn(); + peerConnection.selfDestroy = jest.fn(); + + handleSocketUserExit(peerConnection); + + expect(peerConnection.toggleLockRoom).toBeCalledWith(false); + expect(peerConnection.selfDestroy).toBeCalled(); + }); + }); +}); diff --git a/app/features/PeerConnection/handleSocketUserExit.ts b/app/features/PeerConnection/handleSocketUserExit.ts new file mode 100644 index 0000000..3f4b53d --- /dev/null +++ b/app/features/PeerConnection/handleSocketUserExit.ts @@ -0,0 +1,9 @@ +export default (peerConnection: PeerConnection) => { + if (peerConnection.isSocketRoomLocked) { + peerConnection.toggleLockRoom(false); + if (peerConnection.isCallStarted) { + // TODO: display toast device is gone .... + peerConnection.selfDestroy(); + } + } +}; diff --git a/app/features/PeerConnection/index.spec.ts b/app/features/PeerConnection/index.spec.ts new file mode 100644 index 0000000..f14b9ec --- /dev/null +++ b/app/features/PeerConnection/index.spec.ts @@ -0,0 +1,473 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { ipcRenderer } from 'electron'; +import PeerConnection from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import SharingSessionService from '../SharingSessionService'; +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; +import { + TEST_APP_LANGUAGE, + TEST_APP_THEME, + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, +} from './mocks/testVars'; +import setDisplaySizeFromLocalStream from './handleSetDisplaySizeFromLocalStream'; +import handleSelfDestroy from './handleSelfDestroy'; +import handleRecieveEncryptedMessage from './handleRecieveEncryptedMessage'; +import handleCreatePeer from './handleCreatePeer'; +import { prepare as prepareMessage } from '../../utils/message'; + +jest.useFakeTimers(); + +jest.mock('simple-peer'); +const TEST_SOURCE_DISPLAY_SIZE = { + width: 640, + height: 480, +}; +const TEST_DATA_TO_SEND_IN_ENCRYPTED_MESSAGE = 'oji23oi12p34'; +jest.mock('electron', () => { + return { + ipcRenderer: { + invoke: jest.fn().mockImplementation(() => { + return TEST_SOURCE_DISPLAY_SIZE; + }), + }, + }; +}); +jest.mock('./handleSetDisplaySizeFromLocalStream'); +jest.mock('./handleSelfDestroy'); +jest.mock('../../utils/message', () => { + return { + prepare: jest.fn().mockReturnValue({ + toSend: TEST_DATA_TO_SEND_IN_ENCRYPTED_MESSAGE, + }), + }; +}); +jest.mock('./handleRecieveEncryptedMessage'); +jest.mock('./handleCreatePeer'); + +const TEST_DISPLAY_ID = '21'; + +describe('PeerConnection index.ts tests', () => { + let peerConnection: PeerConnection; + const mockGetSourceDisplayIDBySourceID = jest.fn().mockImplementation(() => { + return TEST_DISPLAY_ID; + }); + + beforeEach(() => { + peerConnection = new PeerConnection( + TEST_ROOM_ID, + TEST_SHARING_SESSION_ID, + TEST_USER, + TEST_APP_THEME, // TODO getAppTheme + TEST_APP_LANGUAGE, // TODO getLanguage + {} as RoomIDService, + {} as ConnectedDevicesService, + {} as SharingSessionService, + ({} as unknown) as DesktopCapturerSourcesService + ); + peerConnection.displayID = 'screen:123idid'; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when PeerConnection constructor was called', () => { + it('should be created with internal properties correclty', () => { + expect(peerConnection.roomIDService).toBeDefined(); + expect(peerConnection.connectedDevicesService).toBeDefined(); + expect(peerConnection.sharingSessionService).toBeDefined(); + }); + + describe('when setAppLanguage was called', () => { + it('should set peerConnection app language and call notifyClientWithNewLanguage', () => { + const TEST_APP_LANG = 'ua'; + const mockNotify = jest.fn(); + peerConnection.notifyClientWithNewLanguage = mockNotify; + + peerConnection.setAppLanguage(TEST_APP_LANG); + + expect(mockNotify).toBeCalled(); + expect(peerConnection.appLanguage).toBe(TEST_APP_LANG); + }); + }); + + describe('when setAppTheme was called', () => { + it('should set peerConnection theme and call notifyClientWithNewColorTheme', () => { + const APP_THEME = true; + const mockNotify = jest.fn(); + peerConnection.notifyClientWithNewColorTheme = mockNotify; + + peerConnection.setAppTheme(APP_THEME); + + expect(mockNotify).toBeCalled(); + expect(peerConnection.appColorTheme).toBe(APP_THEME); + }); + }); + + describe('when notifyClientWithNewLanguage was called', () => { + it('should call sendEncryptedMessage with proper payload', () => { + peerConnection.sendEncryptedMessage = jest.fn(); + + peerConnection.notifyClientWithNewLanguage(); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith({ + type: 'APP_LANGUAGE', + payload: { value: peerConnection.appLanguage }, + }); + }); + }); + + describe('when notifyClientWithNewColorTheme was called', () => { + it('should call sendEncryptedMessage with proper payload', () => { + peerConnection.sendEncryptedMessage = jest.fn(); + + peerConnection.notifyClientWithNewColorTheme(); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith({ + type: 'APP_THEME', + payload: { value: peerConnection.appColorTheme }, + }); + }); + }); + + describe('when setDesktopCapturerSourceID was called', () => { + it('should set .desktopCapturerSourceID and call other callbacks', () => { + const testSourceID = 'screen:asdfsffs1234'; + process.env.RUN_MODE = 'dev'; + peerConnection.setDisplayIDByDesktopCapturerSourceID = jest.fn(); + peerConnection.handleCreatePeerAfterDesktopCapturerSourceIDWasSet = jest.fn(); + + peerConnection.setDesktopCapturerSourceID(testSourceID); + + process.env.RUN_MODE = 'test'; + expect(peerConnection.desktopCapturerSourceID).toBe(testSourceID); + expect( + peerConnection.setDisplayIDByDesktopCapturerSourceID + ).toBeCalled(); + expect( + peerConnection.handleCreatePeerAfterDesktopCapturerSourceIDWasSet + ).toBeCalled(); + }); + }); + + describe('when setDisplayIDByDesktopCapturerSourceID was called', () => { + describe('when desktopCapture source id is screen', () => { + it('should set .desktopCapturerSourceID and call other callbacks', () => { + peerConnection.desktopCapturerSourceID = 'screen:asdfa2'; + peerConnection.setDisplaySizeRetreivedFromMainProcess = jest.fn(); + peerConnection.desktopCapturerSourcesService = ({ + getSourceDisplayIDBySourceID: mockGetSourceDisplayIDBySourceID, + } as unknown) as DesktopCapturerSourcesService; + + peerConnection.setDisplayIDByDesktopCapturerSourceID(); + + expect( + peerConnection.setDisplaySizeRetreivedFromMainProcess + ).toBeCalled(); + expect(mockGetSourceDisplayIDBySourceID).toBeCalled(); + expect(peerConnection.displayID).toBe(TEST_DISPLAY_ID); + }); + }); + + describe('when desktopCapture source id is window', () => { + it('should not set anything', () => { + peerConnection.desktopCapturerSourceID = 'window:asdfa2'; + peerConnection.setDisplaySizeRetreivedFromMainProcess = jest.fn(); + peerConnection.desktopCapturerSourcesService = ({ + getSourceDisplayIDBySourceID: mockGetSourceDisplayIDBySourceID, + } as unknown) as DesktopCapturerSourcesService; + + peerConnection.setDisplayIDByDesktopCapturerSourceID(); + + expect( + peerConnection.setDisplaySizeRetreivedFromMainProcess + ).not.toBeCalled(); + expect(mockGetSourceDisplayIDBySourceID).not.toBeCalled(); + expect(peerConnection.displayID).not.toBe(TEST_DISPLAY_ID); + }); + }); + }); + + describe('when setDisplaySizeRetreivedFromMainProcess was called', () => { + it('should call .invoke on ipcRenderer with proper parameters', async () => { + await peerConnection.setDisplaySizeRetreivedFromMainProcess(); + + expect(ipcRenderer.invoke).toBeCalledWith( + 'get-display-size-by-display-id', + peerConnection.displayID + ); + expect(peerConnection.sourceDisplaySize).toBe(TEST_SOURCE_DISPLAY_SIZE); + }); + + describe('when .invoke returned "undefined"', () => { + it('should not set sourceDisplaySize', async () => { + // @ts-ignore + ipcRenderer.invoke.mockImplementation(() => { + return 'undefined'; + }); + + await peerConnection.setDisplaySizeRetreivedFromMainProcess(); + + expect(ipcRenderer.invoke).toBeCalledWith( + 'get-display-size-by-display-id', + peerConnection.displayID + ); + expect(peerConnection.sourceDisplaySize).not.toBe( + TEST_SOURCE_DISPLAY_SIZE + ); + }); + }); + }); + + describe('when handleCreatePeerAfterDesktopCapturerSourceIDWasSet was called', () => { + describe('when .sourceDisplaySize is defined', () => { + it('should call setDisplaySizeFromLocalStream', async () => { + peerConnection.createPeer = jest.fn(); + peerConnection.sourceDisplaySize = TEST_SOURCE_DISPLAY_SIZE; + + await peerConnection.handleCreatePeerAfterDesktopCapturerSourceIDWasSet(); + + expect(peerConnection.createPeer).toBeCalled(); + expect(setDisplaySizeFromLocalStream).not.toBeCalled(); + }); + }); + + describe('when .sourceDisplaySize is NOT defined', () => { + it('should call setDisplaySizeFromLocalStream', async () => { + peerConnection.createPeer = jest.fn(); + + await peerConnection.handleCreatePeerAfterDesktopCapturerSourceIDWasSet(); + + expect(peerConnection.createPeer).toBeCalled(); + expect(setDisplaySizeFromLocalStream).toBeCalled(); + }); + }); + }); + + describe('when setOnDeviceConnectedCallback was called properly', () => { + it('should set onDeviceConnectedCallback', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const testCallback = (_: Device) => {}; + peerConnection.setOnDeviceConnectedCallback(testCallback); + + expect(peerConnection.onDeviceConnectedCallback).toBe(testCallback); + }); + }); + + describe('when denyConnectionForPartner was called properly', () => { + it('should call sendEncryptedMessage with proper payload and call .disconnectPartner', async () => { + const testPayload = { + type: 'DENY_TO_CONNECT', + payload: {}, + }; + peerConnection.sendEncryptedMessage = jest.fn(); + peerConnection.disconnectPartner = jest.fn(); + + await peerConnection.denyConnectionForPartner(); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith(testPayload); + expect(peerConnection.disconnectPartner).toBeCalled(); + }); + }); + + describe('when sendUserAllowedToConnect was called properly', () => { + it('should call sendEncryptedMessage with proper payload', () => { + const testPayload = { + type: 'ALLOWED_TO_CONNECT', + payload: {}, + }; + peerConnection.sendEncryptedMessage = jest.fn(); + + peerConnection.sendUserAllowedToConnect(); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith(testPayload); + }); + }); + + describe('when disconnectByHostMachineUser was called properly', () => { + it('should call sendEncryptedMessage with proper payload and call .disconnectPartner and .selfDestroy', async () => { + const testPayload = { + type: 'DISCONNECT_BY_HOST_MACHINE_USER', + payload: {}, + }; + peerConnection.sendEncryptedMessage = jest.fn(); + peerConnection.disconnectPartner = jest.fn(); + peerConnection.selfDestroy = jest.fn(); + + await peerConnection.disconnectByHostMachineUser(); + + expect(peerConnection.sendEncryptedMessage).toBeCalledWith(testPayload); + expect(peerConnection.disconnectPartner).toBeCalled(); + expect(peerConnection.selfDestroy).toBeCalled(); + }); + }); + + describe('when disconnectPartner was called properly', () => { + it('should call sendEncryptedMessage with proper payload', () => { + const testEmitData = { + ip: peerConnection.partnerDeviceDetails.deviceIP, + }; + peerConnection.socket = ({ + emit: jest.fn(), + } as unknown) as SocketIOClient.Socket; + + peerConnection.disconnectPartner(); + + expect(peerConnection.socket.emit).toBeCalledWith( + 'DISCONNECT_SOCKET_BY_DEVICE_IP', + testEmitData + ); + expect(peerConnection.partnerDeviceDetails).toEqual({}); + }); + }); + + describe('when selfDestroy was called', () => { + it('should call handleSelfDestroy', () => { + peerConnection.selfDestroy(); + + expect(handleSelfDestroy).toBeCalled(); + }); + }); + + describe('when emitUserEnter was called', () => { + describe('when .socket is defined', () => { + it('should call socket emit with proper parameters', () => { + const testEmitData = { + username: peerConnection.user.username, + publicKey: peerConnection.user.publicKey, + }; + peerConnection.socket = ({ + emit: jest.fn(), + } as unknown) as SocketIOClient.Socket; + + peerConnection.emitUserEnter(); + + expect(peerConnection.socket.emit).toBeCalledWith( + 'USER_ENTER', + testEmitData + ); + }); + }); + }); + + describe('when sendEncryptedMessage was called', () => { + describe('when it was NOT called properly', () => { + it('should not call "prepare" from message.ts if socket is not defined', () => { + peerConnection.socket = (undefined as unknown) as SocketIOClient.Socket; + + peerConnection.sendEncryptedMessage( + ({} as unknown) as SendEncryptedMessagePayload + ); + + expect(prepareMessage).not.toBeCalled(); + }); + + it('should not call "prepare" from message.ts if user is not defined', () => { + peerConnection.user = (undefined as unknown) as LocalPeerUser; + + peerConnection.sendEncryptedMessage( + ({} as unknown) as SendEncryptedMessagePayload + ); + + expect(prepareMessage).not.toBeCalled(); + }); + + it('should not call "prepare" from message.ts if partner is not defined', () => { + peerConnection.partner = (undefined as unknown) as LocalPeerUser; + + peerConnection.sendEncryptedMessage( + ({} as unknown) as SendEncryptedMessagePayload + ); + + expect(prepareMessage).not.toBeCalled(); + }); + }); + + describe('when it was called properly', () => { + it('should call "prepare" from message.ts and .socket.emit(ENCRYPTED_MESSAGE', async () => { + const testPayload = ({} as unknown) as SendEncryptedMessagePayload; + peerConnection.socket = ({ + emit: jest.fn(), + } as unknown) as SocketIOClient.Socket; + peerConnection.partner = TEST_USER; + + await peerConnection.sendEncryptedMessage(testPayload); + + expect(prepareMessage).toBeCalledWith( + testPayload, + TEST_USER, + TEST_USER + ); + expect(peerConnection.socket.emit).toBeCalledWith( + 'ENCRYPTED_MESSAGE', + TEST_DATA_TO_SEND_IN_ENCRYPTED_MESSAGE + ); + }); + }); + }); + + describe('when receiveEncryptedMessage was called', () => { + describe('when peerConnection user is NOT defined', () => { + it('should NOT call handleRecieveEncryptedMessage', () => { + const testPayload = {} as ReceiveEncryptedMessagePayload; + peerConnection.user = (undefined as unknown) as LocalPeerUser; + + peerConnection.receiveEncryptedMessage(testPayload); + + expect(handleRecieveEncryptedMessage).not.toBeCalled(); + }); + }); + + describe('when peerConnection user is defined', () => { + it('should call handleRecieveEncryptedMessage', () => { + const testPayload = {} as ReceiveEncryptedMessagePayload; + + peerConnection.receiveEncryptedMessage(testPayload); + + expect(handleRecieveEncryptedMessage).toBeCalled(); + }); + }); + }); + + describe('when callPeer was called', () => { + describe('when it was called when call already started', () => { + it('should NOT call .sendEncryptedMessage', () => { + process.env.RUN_MODE = 'dev'; + peerConnection.isCallStarted = true; + peerConnection.sendEncryptedMessage = jest.fn(); + peerConnection.signalsDataToCallUser = ['asdfasdf']; + + peerConnection.callPeer(); + + process.env.RUN_MODE = 'test'; + expect(peerConnection.sendEncryptedMessage).not.toBeCalled(); + }); + }); + + describe('when it was called when call NOT started', () => { + it('should call .sendEncryptedMessage', () => { + process.env.RUN_MODE = 'dev'; + peerConnection.sendEncryptedMessage = jest.fn(); + peerConnection.signalsDataToCallUser = ['asdfasdf']; + + peerConnection.callPeer(); + + process.env.RUN_MODE = 'test'; + expect(peerConnection.sendEncryptedMessage).toBeCalled(); + }); + }); + }); + + describe('when createPeer was called', () => { + it('should call handleCreatePeer callback', () => { + peerConnection.createPeer(); + + expect(handleCreatePeer).toBeCalled(); + }); + }); + }); +}); diff --git a/app/features/PeerConnection/index.ts b/app/features/PeerConnection/index.ts index 7b43cc6..8d54803 100644 --- a/app/features/PeerConnection/index.ts +++ b/app/features/PeerConnection/index.ts @@ -1,53 +1,25 @@ /* eslint-disable promise/catch-or-return */ /* eslint-disable class-methods-use-this */ /* eslint-disable @typescript-eslint/lines-between-class-members */ -import { remote, ipcRenderer } from 'electron'; -import uuid from 'uuid'; -import SimplePeer from 'simple-peer'; -import { - prepare as prepareMessage, - process as processMessage, -} from '../../utils/message'; +import { ipcRenderer } from 'electron'; +import { prepare as prepareMessage } from '../../utils/message'; import DeskreenCrypto from '../../utils/crypto'; import ConnectedDevicesService from '../ConnectedDevicesService'; -import SharingSessionStatusEnum from '../SharingSessionsService/SharingSessionStatusEnum'; import RoomIDService from '../../server/RoomIDService'; -import SharingSessionsService from '../SharingSessionsService'; +import SharingSessionService from '../SharingSessionService'; import connectSocket from '../../server/connectSocket'; -import Logger from '../../utils/LoggerWithFilePrefix'; -import DesktopCapturerSources from '../DesktopCapturerSourcesService'; -import setSdpMediaBitrate from './setSdpMediaBitrate'; -import getDesktopSourceStreamBySourceID from './getDesktopSourceStreamBySourceID'; -import prepareDataMessageToSendScreenSourceType from './prepareDataMessageToSendScreenSourceType'; - -const log = new Logger(__filename); - -interface PartnerPeerUser { - username: string; - publicKey: string; -} - -interface ReceiveEncryptedMessagePayload { - payload: string; - signature: string; - iv: string; - keys: { sessionKey: string; signingKey: string }[]; -} - -interface SendEncryptedMessagePayload { - type: string; - payload: Record; -} +import DesktopCapturerSourcesService from '../DesktopCapturerSourcesService'; +import handleCreatePeer from './handleCreatePeer'; +import handleSocket from './handleSocket'; +import handleRecieveEncryptedMessage from './handleRecieveEncryptedMessage'; +import handleSelfDestroy from './handleSelfDestroy'; +import NullUser from './NullUser'; +import NullSimplePeer from './NullSimplePeer'; +import setDisplaySizeFromLocalStream from './handleSetDisplaySizeFromLocalStream'; +import DesktopCapturerSourceType from '../DesktopCapturerSourcesService/DesktopCapturerSourceType'; type DisplaySize = { width: number; height: number }; -const desktopCapturerSourcesService = remote.getGlobal( - 'desktopCapturerSourcesService' -) as DesktopCapturerSources; - -const nullUser = { username: '', publicKey: '', privateKey: '' }; -const nullSimplePeer = new SimplePeer(); - export default class PeerConnection { sharingSessionID: string; roomID: string; @@ -55,7 +27,7 @@ export default class PeerConnection { crypto: DeskreenCrypto; user: LocalPeerUser; partner: PartnerPeerUser; - peer = nullSimplePeer; + peer = NullSimplePeer; desktopCapturerSourceID: string; localStream: MediaStream | null; isSocketRoomLocked: boolean; @@ -64,10 +36,9 @@ export default class PeerConnection { isCallStarted: boolean; roomIDService: RoomIDService; connectedDevicesService: ConnectedDevicesService; - sharingSessionsService: SharingSessionsService; + sharingSessionService: SharingSessionService; + desktopCapturerSourcesService: DesktopCapturerSourcesService; onDeviceConnectedCallback: (device: Device) => void; - prevStreamWidth: number; - prevStreamHeight: number; displayID: string; sourceDisplaySize: DisplaySize | undefined; appLanguage: string; @@ -81,31 +52,35 @@ export default class PeerConnection { appLanguage: string, roomIDService: RoomIDService, connectedDevicesService: ConnectedDevicesService, - sharingSessionsService: SharingSessionsService + sharingSessionsService: SharingSessionService, + desktopCapturerSourcesService: DesktopCapturerSourcesService ) { this.roomIDService = roomIDService; this.connectedDevicesService = connectedDevicesService; - this.sharingSessionsService = sharingSessionsService; + this.sharingSessionService = sharingSessionsService; this.sharingSessionID = sharingSessionID; this.isSocketRoomLocked = false; this.roomID = encodeURI(roomID); this.crypto = new DeskreenCrypto(); this.socket = connectSocket(this.roomID); this.user = user; - this.partner = nullUser; + this.partner = NullUser; this.desktopCapturerSourceID = ''; this.signalsDataToCallUser = []; this.isCallStarted = false; this.localStream = null; - this.prevStreamWidth = -1; - this.prevStreamHeight = -1; this.displayID = ''; this.sourceDisplaySize = undefined; this.appLanguage = appLanguage; this.appColorTheme = appColorTheme; + this.desktopCapturerSourcesService = desktopCapturerSourcesService; this.onDeviceConnectedCallback = () => {}; - this.initSocketWhenUserCreatedCallback(); + handleSocket(this); + + window.addEventListener('beforeunload', () => { + this.socket.emit('USER_DISCONNECT'); + }); } setAppLanguage(lang: string) { @@ -132,32 +107,44 @@ export default class PeerConnection { }); } - setDesktopCapturerSourceID(id: string) { + async setDesktopCapturerSourceID(id: string) { this.desktopCapturerSourceID = id; if (process.env.RUN_MODE === 'test') return; - if (id.includes('screen')) { - this.displayID = desktopCapturerSourcesService.getSourceDisplayIDBySourceID( - id - ); + this.setDisplayIDByDesktopCapturerSourceID(); - if (this.displayID !== '') { - ipcRenderer - .invoke('get-display-size-by-display-id', this.displayID) - .then((size: DisplaySize | 'undefined') => { - if (size !== 'undefined') { - this.sourceDisplaySize = size; - } - return size; - }) - .then(async () => { - await this.createPeer(); - this.setDisplaySizeFromLocalStream(); - return undefined; - }); - } - } else { - this.createPeer(); + this.handleCreatePeerAfterDesktopCapturerSourceIDWasSet(); + } + + setDisplayIDByDesktopCapturerSourceID() { + if ( + !this.desktopCapturerSourceID.includes(DesktopCapturerSourceType.SCREEN) + ) + return; + + this.displayID = this.desktopCapturerSourcesService.getSourceDisplayIDBySourceID( + this.desktopCapturerSourceID + ); + + if (this.displayID !== '') { + this.setDisplaySizeRetreivedFromMainProcess(); + } + } + + async setDisplaySizeRetreivedFromMainProcess() { + const size: DisplaySize | 'undefined' = await ipcRenderer.invoke( + 'get-display-size-by-display-id', + this.displayID + ); + if (size !== 'undefined') { + this.sourceDisplaySize = size; + } + } + + async handleCreatePeerAfterDesktopCapturerSourceIDWasSet() { + await this.createPeer(); + if (!this.sourceDisplaySize) { + setDisplaySizeFromLocalStream(this); } } @@ -165,32 +152,12 @@ export default class PeerConnection { this.onDeviceConnectedCallback = callback; } - setDisplaySizeFromLocalStream() { - if (!this.localStream || !this.localStream.getVideoTracks()[0]) return; - if (!this.localStream.getVideoTracks()[0].getSettings().width) return; - if (!this.localStream.getVideoTracks()[0].getSettings().height) return; - this.sourceDisplaySize = { - width: this.localStream.getVideoTracks()[0].getSettings().width - ? (this.localStream.getVideoTracks()[0].getSettings().width as number) - : 640, - height: this.localStream.getVideoTracks()[0].getSettings().height - ? (this.localStream.getVideoTracks()[0].getSettings().height as number) - : 480, - }; - } - - denyConnectionForPartner() { - this.sendEncryptedMessage({ + async denyConnectionForPartner() { + await this.sendEncryptedMessage({ type: 'DENY_TO_CONNECT', payload: {}, - }) - // eslint-disable-next-line promise/always-return - .then(() => { - this.disconnectPartner(); - }) - .catch((e) => { - log.error(e); - }); + }); + this.disconnectPartner(); } sendUserAllowedToConnect() { @@ -200,19 +167,13 @@ export default class PeerConnection { }); } - disconnectByHostMachineUser() { - this.sendEncryptedMessage({ + async disconnectByHostMachineUser() { + await this.sendEncryptedMessage({ type: 'DISCONNECT_BY_HOST_MACHINE_USER', payload: {}, - }) - // eslint-disable-next-line promise/always-return - .then(() => { - this.disconnectPartner(); - this.selfDestrory(); - }) - .catch((e) => { - log.error(e); - }); + }); + this.disconnectPartner(); + this.selfDestroy(); } disconnectPartner() { @@ -223,106 +184,11 @@ export default class PeerConnection { this.partnerDeviceDetails = {} as Device; } - initSocketWhenUserCreatedCallback() { - this.socket.removeAllListeners(); - - this.socket.on('disconnect', () => { - this.selfDestrory(); - }); - - this.socket.on('connect', () => { - // this.emitUserEnter(); - }); - - this.socket.on('USER_ENTER', (payload: { users: PartnerPeerUser[] }) => { - const filteredPartner = payload.users.filter((user: PartnerPeerUser) => { - return this.user.publicKey !== user.publicKey; - }); - - if (filteredPartner[0] === undefined) return; - - [this.partner] = filteredPartner; - - this.sendEncryptedMessage({ - type: 'ADD_USER', - payload: { - username: this.user.username, - publicKey: this.user.publicKey, - isOwner: true, - id: this.user.username, - }, - }); - - if (this.partner.publicKey !== '') { - this.socket.emit('TOGGLE_LOCK_ROOM', null, () => { - this.isSocketRoomLocked = true; - this.emitUserEnter(); - }); - } - }); - - this.socket.on('USER_EXIT', () => { - if (this.isSocketRoomLocked) { - this.socket.emit('TOGGLE_LOCK_ROOM', null, () => {}); - this.isSocketRoomLocked = false; - - if (this.isCallStarted) { - // TODO: display toast device is gone .... - this.selfDestrory(); - } - } - }); - - this.socket.on( - 'ENCRYPTED_MESSAGE', - (payload: ReceiveEncryptedMessagePayload) => { - this.receiveEncryptedMessage(payload); - } - ); - - this.socket.on('USER_DISCONNECT', () => { - this.socket.emit('TOGGLE_LOCK_ROOM', null, () => {}); - }); - - // socketConnection.on('TOGGLE_LOCK_ROOM', payload => { - // this.props.receiveUnencryptedMessage('TOGGLE_LOCK_ROOM', payload); - // }); - - // socketConnection.on('ROOM_LOCKED', payload => { - // this.props.openModal('Room Locked'); - // }); - - window.addEventListener('beforeunload', () => { - this.socket.emit('USER_DISCONNECT'); - }); - } - - selfDestrory() { - this.partner = nullUser; - this.connectedDevicesService.removeDeviceByID(this.partnerDeviceDetails.id); - if (this.peer !== nullSimplePeer) { - this.peer.destroy(); - } - if (this.localStream) { - this.localStream.getTracks().forEach((track) => { - track.stop(); - }); - this.localStream = null; - } - const sharingSession = this.sharingSessionsService.sharingSessions.get( - this.sharingSessionID - ); - sharingSession?.setStatus(SharingSessionStatusEnum.DESTROYED); - sharingSession?.destory(); - this.sharingSessionsService.sharingSessions.delete(this.sharingSessionID); - this.onDeviceConnectedCallback = () => {}; - this.isCallStarted = false; - this.socket.disconnect(); - this.roomIDService.unmarkRoomIDAsTaken(this.roomID); + selfDestroy() { + handleSelfDestroy(this); } emitUserEnter() { - if (!this.socket) return; this.socket.emit('USER_ENTER', { username: this.user.username, publicKey: this.user.publicKey, @@ -337,44 +203,9 @@ export default class PeerConnection { this.socket.emit('ENCRYPTED_MESSAGE', msg.toSend); } - async receiveEncryptedMessage(payload: ReceiveEncryptedMessagePayload) { + receiveEncryptedMessage(payload: ReceiveEncryptedMessagePayload) { if (!this.user) return; - const message = await processMessage(payload, this.user.privateKey); - if (message.type === 'CALL_ACCEPTED') { - this.peer.signal(message.payload.signalData); - } - if (message.type === 'DEVICE_DETAILS') { - this.socket.emit( - 'GET_IP_BY_SOCKET_ID', - message.payload.socketID, - (deviceIP: string) => { - const device = { - id: uuid.v4(), - deviceIP, - deviceType: message.payload.deviceType, - deviceOS: message.payload.os, - deviceBrowser: message.payload.browser, - deviceScreenWidth: message.payload.deviceScreenWidth, - deviceScreenHeight: message.payload.deviceScreenHeight, - sharingSessionID: this.sharingSessionID, - }; - this.partnerDeviceDetails = device; - this.onDeviceConnectedCallback(device); - } - ); - } - if (message.type === 'GET_APP_THEME') { - this.sendEncryptedMessage({ - type: 'APP_THEME', - payload: { value: this.appColorTheme }, - }); - } - if (message.type === 'GET_APP_LANGUAGE') { - this.sendEncryptedMessage({ - type: 'APP_LANGUAGE', - payload: { value: this.appLanguage }, - }); - } + handleRecieveEncryptedMessage(this, payload); } callPeer() { @@ -393,111 +224,11 @@ export default class PeerConnection { } createPeer() { - return new Promise((resolve) => { - this.createDesktopCapturerStream(this.desktopCapturerSourceID).then( - () => { - const peer = new SimplePeer({ - initiator: true, - // trickle: true, - // stream: this.localStream, - // allowHalfTrickle: false, - config: { iceServers: [] }, - sdpTransform: (sdp) => { - let newSDP = sdp; - newSDP = setSdpMediaBitrate( - newSDP as string, - 'video', - 500000 - ) as typeof sdp; - return newSDP; - }, - }); - - // eslint-disable-next-line promise/always-return - if (this.localStream !== null) { - peer.addStream(this.localStream); - } - - peer.on('signal', (data: string) => { - // fired when simple peer and webrtc done preparation to start call on this machine - this.signalsDataToCallUser.push(data); - }); - - this.peer = peer; - - this.peer.on('data', async (data) => { - const dataJSON = JSON.parse(data); - - if (dataJSON.type === 'set_video_quality') { - const maxVideoQualityMultiplier = dataJSON.payload.value; - const minVideoQualityMultiplier = - maxVideoQualityMultiplier === 1 - ? 0.5 - : maxVideoQualityMultiplier; - - if (!this.desktopCapturerSourceID.includes('screen')) return; - - const newStream = await getDesktopSourceStreamBySourceID( - this.desktopCapturerSourceID, - this.sourceDisplaySize?.width, - this.sourceDisplaySize?.height, - minVideoQualityMultiplier, - maxVideoQualityMultiplier - ); - const newVideoTrack = newStream.getVideoTracks()[0]; - const oldTrack = this.localStream?.getVideoTracks()[0]; - - if (oldTrack && this.localStream) { - peer.replaceTrack(oldTrack, newVideoTrack, this.localStream); - oldTrack.stop(); - } - } - - if (dataJSON.type === 'get_sharing_source_type') { - const sourceType = this.desktopCapturerSourceID.includes('screen') - ? 'screen' - : 'window'; - - this.peer.send( - prepareDataMessageToSendScreenSourceType(sourceType) - ); - } - }); - resolve(undefined); - } - ); - }); + return handleCreatePeer(this); } - // TODO: move outside this file - createDesktopCapturerStream(sourceID: string) { - return new Promise((resolve) => { - try { - if (process.env.RUN_MODE === 'test') resolve(undefined); - - if (!sourceID.includes('screen')) { - getDesktopSourceStreamBySourceID(sourceID).then((stream) => { - this.localStream = stream; - resolve(undefined); - return stream; - }); - } else { - // when screen source id - getDesktopSourceStreamBySourceID( - sourceID, - this.sourceDisplaySize?.width, - this.sourceDisplaySize?.height, - 0.5, - 1 - ).then((stream) => { - this.localStream = stream; - resolve(undefined); - return stream; - }); - } - } catch (e) { - log.error(e); - } - }); + toggleLockRoom(isConnected: boolean) { + this.socket.emit('TOGGLE_LOCK_ROOM'); + this.isSocketRoomLocked = isConnected; } } diff --git a/app/features/PeerConnection/mocks/INPUTvideo500000testSdpMediaBitrate.ts b/app/features/PeerConnection/mocks/INPUTvideo500000testSdpMediaBitrate.ts new file mode 100644 index 0000000..c648581 --- /dev/null +++ b/app/features/PeerConnection/mocks/INPUTvideo500000testSdpMediaBitrate.ts @@ -0,0 +1,109 @@ +// eslint-disable-next-line import/prefer-default-export +export const INPUTtestSdpMediaBitrate = ` +v=0 +o=- 5730467698688819135 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 121 127 120 125 107 108 109 114 115 116 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:PY+h +a=ice-pwd:eYoy9PHXsilgXAbK7MSIMUJc +a=ice-options:trickle +a=fingerprint:sha-256 73:1D:63:11:3E:2F:A4:AA:ED:37:4B:D6:0F:A2:60:7A:A3:9B:EC:D9:D1:AF:C3:E0:53:59:4A:E1:D5:A9:EF:2D +a=setup:active +a=mid:0 +a=extmap:1 urn:ietf:params:rtp-hdrext:toffset +a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time +a=extmap:3 urn:3gpp:video-orientation +a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 +a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay +a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type +a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing +a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space +a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid +a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id +a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id +a=recvonly +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:96 VP8/90000 +a=rtcp-fb:96 goog-remb +a=rtcp-fb:96 transport-cc +a=rtcp-fb:96 ccm fir +a=rtcp-fb:96 nack +a=rtcp-fb:96 nack pli +a=rtpmap:97 rtx/90000 +a=fmtp:97 apt=96 +a=rtpmap:98 VP9/90000 +a=rtcp-fb:98 goog-remb +a=rtcp-fb:98 transport-cc +a=rtcp-fb:98 ccm fir +a=rtcp-fb:98 nack +a=rtcp-fb:98 nack pli +a=fmtp:98 profile-id=0 +a=rtpmap:99 rtx/90000 +a=fmtp:99 apt=98 +a=rtpmap:100 VP9/90000 +a=rtcp-fb:100 goog-remb +a=rtcp-fb:100 transport-cc +a=rtcp-fb:100 ccm fir +a=rtcp-fb:100 nack +a=rtcp-fb:100 nack pli +a=fmtp:100 profile-id=2 +a=rtpmap:101 rtx/90000 +a=fmtp:101 apt=100 +a=rtpmap:102 H264/90000 +a=rtcp-fb:102 goog-remb +a=rtcp-fb:102 transport-cc +a=rtcp-fb:102 ccm fir +a=rtcp-fb:102 nack +a=rtcp-fb:102 nack pli +a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f +a=rtpmap:121 rtx/90000 +a=fmtp:121 apt=102 +a=rtpmap:127 H264/90000 +a=rtcp-fb:127 goog-remb +a=rtcp-fb:127 transport-cc +a=rtcp-fb:127 ccm fir +a=rtcp-fb:127 nack +a=rtcp-fb:127 nack pli +a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f +a=rtpmap:120 rtx/90000 +a=fmtp:120 apt=127 +a=rtpmap:125 H264/90000 +a=rtcp-fb:125 goog-remb +a=rtcp-fb:125 transport-cc +a=rtcp-fb:125 ccm fir +a=rtcp-fb:125 nack +a=rtcp-fb:125 nack pli +a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f +a=rtpmap:107 rtx/90000 +a=fmtp:107 apt=125 +a=rtpmap:108 H264/90000 +a=rtcp-fb:108 goog-remb +a=rtcp-fb:108 transport-cc +a=rtcp-fb:108 ccm fir +a=rtcp-fb:108 nack +a=rtcp-fb:108 nack pli +a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f +a=rtpmap:109 rtx/90000 +a=fmtp:109 apt=108 +a=rtpmap:114 red/90000 +a=rtpmap:115 rtx/90000 +a=fmtp:115 apt=114 +a=rtpmap:116 ulpfec/90000 +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +b=AS:30 +a=ice-ufrag:PY+h +a=ice-pwd:eYoy9PHXsilgXAbK7MSIMUJc +a=ice-options:trickle +a=fingerprint:sha-256 73:1D:63:11:3E:2F:A4:AA:ED:37:4B:D6:0F:A2:60:7A:A3:9B:EC:D9:D1:AF:C3:E0:53:59:4A:E1:D5:A9:EF:2D +a=setup:active +a=mid:1 +a=sctp-port:5000 +a=max-message-size:262144 +`; diff --git a/app/features/PeerConnection/mocks/OUTPUTvideo500000testSdpMediaBitrate.ts b/app/features/PeerConnection/mocks/OUTPUTvideo500000testSdpMediaBitrate.ts new file mode 100644 index 0000000..152e3e1 --- /dev/null +++ b/app/features/PeerConnection/mocks/OUTPUTvideo500000testSdpMediaBitrate.ts @@ -0,0 +1,110 @@ +// eslint-disable-next-line import/prefer-default-export +export const OUTPUTtestSdpMediaBitrate = ` +v=0 +o=- 5730467698688819135 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 121 127 120 125 107 108 109 114 115 116 +c=IN IP4 0.0.0.0 +b=AS:500000 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:PY+h +a=ice-pwd:eYoy9PHXsilgXAbK7MSIMUJc +a=ice-options:trickle +a=fingerprint:sha-256 73:1D:63:11:3E:2F:A4:AA:ED:37:4B:D6:0F:A2:60:7A:A3:9B:EC:D9:D1:AF:C3:E0:53:59:4A:E1:D5:A9:EF:2D +a=setup:active +a=mid:0 +a=extmap:1 urn:ietf:params:rtp-hdrext:toffset +a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time +a=extmap:3 urn:3gpp:video-orientation +a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 +a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay +a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type +a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing +a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space +a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid +a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id +a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id +a=recvonly +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:96 VP8/90000 +a=rtcp-fb:96 goog-remb +a=rtcp-fb:96 transport-cc +a=rtcp-fb:96 ccm fir +a=rtcp-fb:96 nack +a=rtcp-fb:96 nack pli +a=rtpmap:97 rtx/90000 +a=fmtp:97 apt=96 +a=rtpmap:98 VP9/90000 +a=rtcp-fb:98 goog-remb +a=rtcp-fb:98 transport-cc +a=rtcp-fb:98 ccm fir +a=rtcp-fb:98 nack +a=rtcp-fb:98 nack pli +a=fmtp:98 profile-id=0 +a=rtpmap:99 rtx/90000 +a=fmtp:99 apt=98 +a=rtpmap:100 VP9/90000 +a=rtcp-fb:100 goog-remb +a=rtcp-fb:100 transport-cc +a=rtcp-fb:100 ccm fir +a=rtcp-fb:100 nack +a=rtcp-fb:100 nack pli +a=fmtp:100 profile-id=2 +a=rtpmap:101 rtx/90000 +a=fmtp:101 apt=100 +a=rtpmap:102 H264/90000 +a=rtcp-fb:102 goog-remb +a=rtcp-fb:102 transport-cc +a=rtcp-fb:102 ccm fir +a=rtcp-fb:102 nack +a=rtcp-fb:102 nack pli +a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f +a=rtpmap:121 rtx/90000 +a=fmtp:121 apt=102 +a=rtpmap:127 H264/90000 +a=rtcp-fb:127 goog-remb +a=rtcp-fb:127 transport-cc +a=rtcp-fb:127 ccm fir +a=rtcp-fb:127 nack +a=rtcp-fb:127 nack pli +a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f +a=rtpmap:120 rtx/90000 +a=fmtp:120 apt=127 +a=rtpmap:125 H264/90000 +a=rtcp-fb:125 goog-remb +a=rtcp-fb:125 transport-cc +a=rtcp-fb:125 ccm fir +a=rtcp-fb:125 nack +a=rtcp-fb:125 nack pli +a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f +a=rtpmap:107 rtx/90000 +a=fmtp:107 apt=125 +a=rtpmap:108 H264/90000 +a=rtcp-fb:108 goog-remb +a=rtcp-fb:108 transport-cc +a=rtcp-fb:108 ccm fir +a=rtcp-fb:108 nack +a=rtcp-fb:108 nack pli +a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f +a=rtpmap:109 rtx/90000 +a=fmtp:109 apt=108 +a=rtpmap:114 red/90000 +a=rtpmap:115 rtx/90000 +a=fmtp:115 apt=114 +a=rtpmap:116 ulpfec/90000 +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +b=AS:30 +a=ice-ufrag:PY+h +a=ice-pwd:eYoy9PHXsilgXAbK7MSIMUJc +a=ice-options:trickle +a=fingerprint:sha-256 73:1D:63:11:3E:2F:A4:AA:ED:37:4B:D6:0F:A2:60:7A:A3:9B:EC:D9:D1:AF:C3:E0:53:59:4A:E1:D5:A9:EF:2D +a=setup:active +a=mid:1 +a=sctp-port:5000 +a=max-message-size:262144 +`; diff --git a/app/features/PeerConnection/mocks/testVars.ts b/app/features/PeerConnection/mocks/testVars.ts new file mode 100644 index 0000000..cb7c81e --- /dev/null +++ b/app/features/PeerConnection/mocks/testVars.ts @@ -0,0 +1,9 @@ +export const TEST_ROOM_ID = '1'; +export const TEST_SHARING_SESSION_ID = '123'; +export const TEST_USER = { + username: 'asd', + publicKey: 'nvxm,zv', + privateKey: '14234', +}; +export const TEST_APP_THEME = false; +export const TEST_APP_LANGUAGE = 'en'; diff --git a/app/features/PeerConnection/setSdpMediaBitrate.spec.ts b/app/features/PeerConnection/setSdpMediaBitrate.spec.ts new file mode 100644 index 0000000..106f9f0 --- /dev/null +++ b/app/features/PeerConnection/setSdpMediaBitrate.spec.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { INPUTtestSdpMediaBitrate } from './mocks/INPUTvideo500000testSdpMediaBitrate'; +import { OUTPUTtestSdpMediaBitrate } from './mocks/OUTPUTvideo500000testSdpMediaBitrate'; +import setSdpMediaBitrate from './setSdpMediaBitrate'; + +describe('when setSdpMediaBitrate is called', () => { + it('should return proper sdp media bitrate', () => { + const res = setSdpMediaBitrate(INPUTtestSdpMediaBitrate, 'video', 500000); + + expect(res).toEqual(OUTPUTtestSdpMediaBitrate); + }); +}); diff --git a/app/features/PeerConnection/simplePeerHandleSdpTransform.spec.ts b/app/features/PeerConnection/simplePeerHandleSdpTransform.spec.ts new file mode 100644 index 0000000..c1d49e6 --- /dev/null +++ b/app/features/PeerConnection/simplePeerHandleSdpTransform.spec.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { INPUTtestSdpMediaBitrate } from './mocks/INPUTvideo500000testSdpMediaBitrate'; +import { OUTPUTtestSdpMediaBitrate } from './mocks/OUTPUTvideo500000testSdpMediaBitrate'; +import simplePeerHandleSdpTransform from './simplePeerHandleSdpTransform'; +import setSdpMediaBitrate from './setSdpMediaBitrate'; + +jest.useFakeTimers(); +jest.mock('./setSdpMediaBitrate', () => { + return jest.fn(); +}); + +describe('when simplePeerHandleSdpTransform is called', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + it('should call setSdpMediaBitrate', () => { + simplePeerHandleSdpTransform(INPUTtestSdpMediaBitrate); + + expect(setSdpMediaBitrate).toBeCalled(); + }); + + it('should return proper sdp media bitrate', () => { + // @ts-ignore + setSdpMediaBitrate.mockImplementation( + jest.requireActual('./setSdpMediaBitrate').default + ); + + const res = simplePeerHandleSdpTransform(INPUTtestSdpMediaBitrate); + + expect(res).toEqual(OUTPUTtestSdpMediaBitrate); + }); +}); diff --git a/app/features/PeerConnection/simplePeerHandleSdpTransform.ts b/app/features/PeerConnection/simplePeerHandleSdpTransform.ts new file mode 100644 index 0000000..854c7ac --- /dev/null +++ b/app/features/PeerConnection/simplePeerHandleSdpTransform.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import setSdpMediaBitrate from './setSdpMediaBitrate'; + +export default (sdp: any) => { + let newSDP = sdp; + newSDP = setSdpMediaBitrate(newSDP as string, 'video', 500000) as typeof sdp; + return newSDP; +}; diff --git a/app/features/PeerConnectionHelperRendererService/index.spec.ts b/app/features/PeerConnectionHelperRendererService/index.spec.ts new file mode 100644 index 0000000..7be20b2 --- /dev/null +++ b/app/features/PeerConnectionHelperRendererService/index.spec.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import path from 'path'; +import { BrowserWindow } from 'electron'; +import PeerConnectionHelperRendererService from '.'; + +jest.useFakeTimers(); + +jest.mock('electron', () => { + return { + BrowserWindow: jest.fn().mockImplementation(() => { + return { + loadURL: jest.fn(), + webContents: { + on: jest.fn(), + send: jest.fn(), + toggleDevTools: jest.fn(), + }, + on: jest.fn(), + }; + }), + }; +}); + +const testAppPath = '/a/b/c/descreen_app'; +const testBrowserWindowParams = { + show: false, + // width: 300, + // height: 300, + // x: 2147483647, + // y: 2147483647, + // transparent: true, + // frame: false, + // // skipTaskbar: true, + // focusable: false, + // // parent: mainWindow, + // hasShadow: false, + // titleBarStyle: 'hidden', + webPreferences: + (process.env.NODE_ENV === 'development' || + process.env.E2E_BUILD === 'true') && + process.env.ERB_SECURE !== 'true' + ? { + nodeIntegration: true, + enableRemoteModule: true, + } + : { + preload: path.join( + testAppPath, + 'dist/peerConnectionHelperRendererWindow.renderer.prod.js' + ), + enableRemoteModule: true, + }, +}; + +describe('PeerConnectionHelperRendererService tests', () => { + let service: PeerConnectionHelperRendererService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + service = new PeerConnectionHelperRendererService(testAppPath); + }); + + describe('when PeerConnectionHelperRendererService created properly', () => { + it('should set appPath to what was passed to constructor', () => { + expect(service.appPath).toEqual(testAppPath); + }); + + describe('when createPeerConnectionHelperRenderer was called', () => { + it('should call new BrowserWindow from electron', () => { + service.createPeerConnectionHelperRenderer(); + + expect(BrowserWindow).toHaveBeenCalledTimes(1); + expect(BrowserWindow).toHaveBeenCalledWith(testBrowserWindowParams); + }); + + describe('when process.env.NODE_ENV === dev', () => { + it('should open developer tools', () => { + const prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'dev'; + const window = service.createPeerConnectionHelperRenderer(); + + expect(window.webContents.toggleDevTools).toBeCalled(); + + process.env.NODE_ENV = prevNodeEnv; + }); + }); + + describe('when .on(did-finish-load callback executed', () => { + it('should call .webContents.send with start-peer-connection', () => { + const window = service.createPeerConnectionHelperRenderer(); + + // @ts-ignore + const callback = window.webContents.on.mock.calls[0][1]; // get .on('did-finish-load' mock call + callback(); + + expect(window.webContents.send).toBeCalledWith( + 'start-peer-connection' + ); + }); + }); + }); + }); +}); diff --git a/app/features/PeerConnectionHelperRendererService/index.ts b/app/features/PeerConnectionHelperRendererService/index.ts index 60b2929..9b0ab93 100644 --- a/app/features/PeerConnectionHelperRendererService/index.ts +++ b/app/features/PeerConnectionHelperRendererService/index.ts @@ -66,6 +66,7 @@ export default class RendererWebrtcHelpersService { if (process.env.NODE_ENV === 'dev') { helperRendererWindow.webContents.toggleDevTools(); } + // helperRendererWindow.webContents.toggleDevTools(); return helperRendererWindow; } diff --git a/app/features/SharingSessionsService/LocalPeerUser.d.ts b/app/features/SharingSessionService/LocalPeerUser.d.ts similarity index 100% rename from app/features/SharingSessionsService/LocalPeerUser.d.ts rename to app/features/SharingSessionService/LocalPeerUser.d.ts diff --git a/app/features/SharingSessionService/SharingSession.spec.ts b/app/features/SharingSessionService/SharingSession.spec.ts new file mode 100644 index 0000000..1326e63 --- /dev/null +++ b/app/features/SharingSessionService/SharingSession.spec.ts @@ -0,0 +1,282 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import SharingSession from './SharingSession'; +import SharingSessionStatusEnum from './SharingSessionStatusEnum'; +import SharingType from './SharingTypeEnum'; + +jest.useFakeTimers(); + +const testAppLang = 'ua'; +const testAppTheme = true; +const testUser = { + username: '', + privateKey: '', + publicKey: '', +}; + +describe('SharingSession unit tests', () => { + let sharingSession: SharingSession; + + beforeEach(() => { + process.env.RUN_MODE = 'test-jest'; + sharingSession = new SharingSession( + '1234', + testUser, + { + // @ts-ignore: fine here + createPeerConnectionHelperRenderer: () => { + return { + webContents: { + on: jest.fn(), + send: jest.fn(), + }, + close: jest.fn(), + }; + }, + }, + testAppLang, + testAppTheme + ); + }); + + afterEach(() => { + process.env.RUN_MODE = 'test'; + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when new SahringSession() is created', () => { + it('should create new SharingSession with id', () => { + expect(sharingSession.id).toBeTruthy(); + }); + + it('should crete new SharingSession with deviceID equal to "" ', () => { + expect(sharingSession.deviceID).toBe(''); + }); + + it('should create new SharingSession with sharingType equal to NOT_SET', () => { + expect(sharingSession.sharingType).toBe(SharingType.NOT_SET); + }); + + it('should create new SharingSession with sharingStream set to null', () => { + expect(sharingSession.sharingStream).toBe(null); + }); + + it('should create new SharingSession with roomID', () => { + expect(sharingSession.roomID).toBeTruthy(); + }); + + it('should create new SharingSession with connectedDeviceAt set to null', () => { + expect(sharingSession.connectedDeviceAt).toBe(null); + }); + + it('should create new SharingSession with sharingStartedAt set to null', () => { + expect(sharingSession.sharingStartedAt).toBe(null); + }); + + it('should create new SharingSession with status set to NOT_CONNECTED', () => { + expect(sharingSession.status).toBe( + SharingSessionStatusEnum.NOT_CONNECTED + ); + }); + + it('should create new SharingSession with statusChangeListeners.length to be 1', () => { + expect(sharingSession.statusChangeListeners.length).toBe(1); + }); + + describe('when .peerConnectionHelperRenderer.webContents.on(did-finish-load event occured', () => { + it('should call .peerConnectionHelperRenderer?.webContents.send( with proper parameters', () => { + const callback = + // @ts-ignore + sharingSession.peerConnectionHelperRenderer?.webContents.on.mock + .calls[0][1]; + + callback(); + + expect( + sharingSession.peerConnectionHelperRenderer?.webContents.send + ).toBeCalledWith('create-peer-connection-with-data', { + roomID: sharingSession.roomID, + sharingSessionID: sharingSession.id, + user: testUser, + appTheme: testAppTheme, + appLanguage: testAppLang, + }); + }); + }); + + describe('when .peerConnectionHelperRenderer.webContents.on("ipc-message" event occured on "peer-connected" channel and when onDeviceConnectedCallback is defined', () => { + it('should call .onDeviceConnectedCallback(data) with proper data', () => { + const testData = 'alsi33i223'; + const testCallback = jest.fn(); + const callback = + // @ts-ignore + sharingSession.peerConnectionHelperRenderer?.webContents.on.mock + .calls[1][1]; + sharingSession.onDeviceConnectedCallback = testCallback; + + callback(undefined, 'peer-connected', testData); + + expect(testCallback).toBeCalledWith(testData); + }); + }); + + describe('when .peerConnectionHelperRenderer.webContents.on("ipc-message" event occured NOT on "peer-connected" channel or when .onDeviceConnectedCallback is UNdefined', () => { + it('should call .onDeviceConnectedCallback()', () => { + const testData = 'alsi33i223'; + const testCallback = jest.fn(); + const callback = + // @ts-ignore + sharingSession.peerConnectionHelperRenderer?.webContents.on.mock + .calls[1][1]; + sharingSession.onDeviceConnectedCallback = testCallback; + + callback(undefined, 'random-channel!!', testData); + expect(testCallback).not.toBeCalled(); + + sharingSession.onDeviceConnectedCallback = undefined; + callback(undefined, 'peer-connected', testData); + + expect(testCallback).not.toBeCalled(); + }); + }); + }); + + describe('when addStatusChangeListener is called', () => { + it('should have statusChangeListeners.length of 1', () => { + sharingSession.addStatusChangeListener(() => {}); + + expect(sharingSession.statusChangeListeners.length).toBe(2); + }); + }); + + describe('when notifyStatusChangeListeners is called', () => { + it('should invoke all statusChangeListeners', async () => { + const mockStatusChangeListener1 = jest.fn(); + const mockStatusChangeListener2 = jest.fn(); + sharingSession.addStatusChangeListener(mockStatusChangeListener1); + sharingSession.addStatusChangeListener(mockStatusChangeListener2); + + await sharingSession.notifyStatusChangeListeners(); + + expect(mockStatusChangeListener1).toBeCalled(); + expect(mockStatusChangeListener2).toBeCalled(); + }); + }); + + describe('when setStatus is called', () => { + it('should invoke notifyStatusChangeListeners', async () => { + const mockNotifyStatusChangeListeners = jest.fn(); + sharingSession.notifyStatusChangeListeners = mockNotifyStatusChangeListeners; + + sharingSession.setStatus(SharingSessionStatusEnum.CONNECTED); + + expect(mockNotifyStatusChangeListeners).toBeCalled(); + }); + }); + + describe('when updateStatus is called with SharingSessionStatus argument', () => { + it('should set SharingSession.status as passed in updateStatus argument', async () => { + sharingSession.setStatus(SharingSessionStatusEnum.CONNECTED); + + expect(sharingSession.status).toBe(SharingSessionStatusEnum.CONNECTED); + + sharingSession.setStatus(SharingSessionStatusEnum.SHARING); + + expect(sharingSession.status).toBe(SharingSessionStatusEnum.SHARING); + }); + }); + + describe('when setDeviceID is called with deviceID argument', () => { + it('should set SharingSession.deviceID as in setDeviceID passed argument', async () => { + const testDeviceID = '8989'; + sharingSession.setDeviceID(testDeviceID); + + expect(sharingSession.deviceID).toBe(testDeviceID); + }); + }); + + describe('when destroy() is called', () => { + it('should call peerConnectionHelperRenderer.close()', () => { + sharingSession.destroy(); + + expect(sharingSession.peerConnectionHelperRenderer?.close).toBeCalled(); + }); + }); + + describe('when setOnDeviceConnectedCallback() is called', () => { + it('should set a .onDeviceConnectedCallback same as passed in parameter', () => { + const testCallback = (_: Device) => {}; + sharingSession.setOnDeviceConnectedCallback(testCallback); + + expect(sharingSession.onDeviceConnectedCallback).toBe(testCallback); + }); + }); + + describe('when setDesktopCapturerSourceID() is called', () => { + it('should set a .desktopCapturerSourceID and call .webContents.send with proper parameters', () => { + const testID = '2o20d'; + + sharingSession.setDesktopCapturerSourceID(testID); + + expect(sharingSession.desktopCapturerSourceID).toEqual(testID); + expect( + sharingSession.peerConnectionHelperRenderer?.webContents.send + ).toBeCalledWith('set-desktop-capturer-source-id', testID); + }); + }); + + describe('when callPeer() is called', () => { + it('should call .webContents.send with proper event name', () => { + sharingSession.callPeer(); + + expect( + sharingSession.peerConnectionHelperRenderer?.webContents.send + ).toBeCalledWith('call-peer'); + }); + }); + + describe('when disconnectByHostMachineUser() is called', () => { + it('should call .webContents.send with proper event name', () => { + sharingSession.disconnectByHostMachineUser(); + + expect( + sharingSession.peerConnectionHelperRenderer?.webContents.send + ).toBeCalledWith('disconnect-by-host-machine-user'); + }); + }); + + describe('when denyConnectionForPartner() is called', () => { + it('should call .webContents.send with proper event name', () => { + sharingSession.denyConnectionForPartner(); + + expect( + sharingSession.peerConnectionHelperRenderer?.webContents.send + ).toBeCalledWith('deny-connection-for-partner'); + }); + }); + + describe('when appLanguageChanged() is called', () => { + it('should call .webContents.send with proper event name', () => { + const testLang = 'ua'; + + sharingSession.appLanguageChanged(testLang); + + expect( + sharingSession.peerConnectionHelperRenderer?.webContents.send + ).toBeCalledWith('app-language-changed', testLang); + }); + }); + + describe('when appThemeChanged() is called', () => { + it('should call .webContents.send with proper event name', () => { + const testTheme = true; + + sharingSession.appThemeChanged(testTheme); + + expect( + sharingSession.peerConnectionHelperRenderer?.webContents.send + ).toBeCalledWith('app-color-theme-changed', testTheme); + }); + }); +}); diff --git a/app/features/SharingSessionsService/SharingSession.ts b/app/features/SharingSessionService/SharingSession.ts similarity index 94% rename from app/features/SharingSessionsService/SharingSession.ts rename to app/features/SharingSessionService/SharingSession.ts index ce8a56d..462fa67 100644 --- a/app/features/SharingSessionsService/SharingSession.ts +++ b/app/features/SharingSessionService/SharingSession.ts @@ -5,7 +5,7 @@ import SharingSessionStatusEnum from './SharingSessionStatusEnum'; import SharingTypeEnum from './SharingTypeEnum'; import PeerConnectionHelperRendererService from '../PeerConnectionHelperRendererService'; -type OnDeviceConnectedCallbackType = (device: Device) => void; +// type OnDeviceConnectedCallbackType = undefined | (device: Device) => void; export default class SharingSession { id: string; @@ -18,7 +18,7 @@ export default class SharingSession { status: SharingSessionStatusEnum; statusChangeListeners: SharingSessionStatusChangeListener[]; peerConnectionHelperRenderer: BrowserWindow | undefined; - onDeviceConnectedCallback: OnDeviceConnectedCallbackType; + onDeviceConnectedCallback: undefined | ((device: Device) => void); desktopCapturerSourceID: string; constructor( @@ -38,7 +38,7 @@ export default class SharingSession { this.status = SharingSessionStatusEnum.NOT_CONNECTED; this.statusChangeListeners = [] as SharingSessionStatusChangeListener[]; this.desktopCapturerSourceID = ''; - this.onDeviceConnectedCallback = (() => {}) as OnDeviceConnectedCallbackType; + this.onDeviceConnectedCallback = undefined; if (process.env.RUN_MODE === 'test') return; @@ -77,7 +77,7 @@ export default class SharingSession { }); } - destory() { + destroy() { this.peerConnectionHelperRenderer?.close(); } diff --git a/app/features/SharingSessionsService/SharingSessionStatusChangeListener.d.ts b/app/features/SharingSessionService/SharingSessionStatusChangeListener.d.ts similarity index 100% rename from app/features/SharingSessionsService/SharingSessionStatusChangeListener.d.ts rename to app/features/SharingSessionService/SharingSessionStatusChangeListener.d.ts diff --git a/app/features/SharingSessionsService/SharingSessionStatusEnum.ts b/app/features/SharingSessionService/SharingSessionStatusEnum.ts similarity index 100% rename from app/features/SharingSessionsService/SharingSessionStatusEnum.ts rename to app/features/SharingSessionService/SharingSessionStatusEnum.ts diff --git a/app/features/SharingSessionsService/SharingTypeEnum.ts b/app/features/SharingSessionService/SharingTypeEnum.ts similarity index 100% rename from app/features/SharingSessionsService/SharingTypeEnum.ts rename to app/features/SharingSessionService/SharingTypeEnum.ts diff --git a/app/features/SharingSessionsService/__mocks__/PeerConnection.ts b/app/features/SharingSessionService/__mocks__/PeerConnection.ts similarity index 100% rename from app/features/SharingSessionsService/__mocks__/PeerConnection.ts rename to app/features/SharingSessionService/__mocks__/PeerConnection.ts diff --git a/app/features/SharingSessionsService/__mocks__/shortid.ts b/app/features/SharingSessionService/__mocks__/shortid.ts similarity index 100% rename from app/features/SharingSessionsService/__mocks__/shortid.ts rename to app/features/SharingSessionService/__mocks__/shortid.ts diff --git a/app/features/SharingSessionService/index.spec.ts b/app/features/SharingSessionService/index.spec.ts new file mode 100644 index 0000000..57351d1 --- /dev/null +++ b/app/features/SharingSessionService/index.spec.ts @@ -0,0 +1,214 @@ +import SharingSessionStatusEnum from './SharingSessionStatusEnum'; +import SharingSession from './SharingSession'; +import SharingSessionService from '.'; +import RoomIDService from '../../server/RoomIDService'; +import ConnectedDevicesService from '../ConnectedDevicesService'; +import PeerConnectionHelperRendererService from '../PeerConnectionHelperRendererService'; + +// this may look as an ugly mock, but hey, this works! and don't forget that it is a unit test +// why do we make it like that ? bacuse jest doesnt allow ex. +// duplicated __mock__/electron in different subfolders of the project, so.. better do mainual mock in a test file itself +// jest bug reference on duplicated mocks found: https://github.com/facebook/jest/issues/2070 +// it is a bad design of jest itself by default, so this is the best workaround, simply by making manual mock in this way: +jest.mock('../PeerConnectionHelperRendererService', () => { + return jest.fn().mockImplementation(() => { + return { + createPeerConnectionHelper: () => { + return { + webContents: { + on: () => {}, + toggleDevTools: () => {}, + }, + }; + }, + }; + }); +}); + +jest.useFakeTimers(); + +describe('SharingSessionService unit tests', () => { + let sharingSessionService: SharingSessionService; + + beforeEach(() => { + sharingSessionService = new SharingSessionService( + new RoomIDService(), + new ConnectedDevicesService(), + new PeerConnectionHelperRendererService('') + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when new SharingSessionService() is created', () => { + it('should have empty sharingSessions Map', () => { + expect(sharingSessionService.sharingSessions.size).toBe(0); + }); + + it('should have waitingForConnectionSharingSession set to null', () => { + expect(sharingSessionService.waitingForConnectionSharingSession).toBe( + null + ); + }); + + it('should have pollForInactiveSessions be called', () => { + const backup = SharingSessionService.prototype.pollForInactiveSessions; + + const mockPollForInactiveSessions = jest.fn(); + jest + .spyOn(SharingSessionService.prototype, 'pollForInactiveSessions') + .mockImplementation(mockPollForInactiveSessions); + + // eslint-disable-next-line no-new + new SharingSessionService( + new RoomIDService(), + new ConnectedDevicesService(), + new PeerConnectionHelperRendererService('') + ); + + jest.advanceTimersByTime(1000 * 60 * 60 * 30); // thirty hours later + + expect(mockPollForInactiveSessions).toBeCalled(); + + SharingSessionService.prototype.pollForInactiveSessions = backup; + }); + }); + + describe('when createNewSharingSession is called', () => { + it('should have sharingSessions Map with size equal to 1', async () => { + await sharingSessionService.createNewSharingSession(''); + + expect(sharingSessionService.sharingSessions.size).toBe(1); + }); + + it('should have returned SharingSession object', async () => { + expect( + await sharingSessionService.createNewSharingSession('') + ).toBeInstanceOf(SharingSession); + }); + }); + + describe('when pollForInactiveSessions is called', () => { + it('should have removed SharingSession with status ERROR from sharingSessions Map', async () => { + const testSharingSession = await sharingSessionService.createNewSharingSession( + '' + ); + testSharingSession.status = SharingSessionStatusEnum.ERROR; + + sharingSessionService.pollForInactiveSessions(); + + expect(sharingSessionService.sharingSessions.size).toBe(0); + }); + }); + + describe('when setAppLanguage is called', () => { + it('should set app language accordingly', () => { + const testLang = 'be'; + + sharingSessionService.setAppLanguage(testLang); + + expect(sharingSessionService.appLanguage).toBe(testLang); + }); + }); + + describe('when setAppTheme is called', () => { + it('should set app language accordingly', () => { + const testTheme = true; + + sharingSessionService.setAppTheme(testTheme); + + expect(sharingSessionService.isDarkTheme).toBe(testTheme); + }); + }); + + describe('when createWaitingForConnectionSharingSession is called', () => { + it('should call waitWhileUserIsNotCreated', async () => { + sharingSessionService.waitWhileUserIsNotCreated = jest + .fn() + .mockImplementation(() => { + return new Promise((resolve) => resolve(undefined)); + }); + + await sharingSessionService.createWaitingForConnectionSharingSession(''); + + expect(sharingSessionService.waitWhileUserIsNotCreated).toBeCalled(); + }); + + describe('when user created', () => { + it('should call createNewSharingSession with roomID', async () => { + const testRoomID = '12342341'; + sharingSessionService.waitWhileUserIsNotCreated = jest + .fn() + .mockImplementation(() => { + return new Promise((resolve) => resolve(undefined)); + }); + sharingSessionService.createNewSharingSession = jest.fn(); + + await sharingSessionService.createWaitingForConnectionSharingSession( + testRoomID + ); + + expect(sharingSessionService.createNewSharingSession).toBeCalledWith( + testRoomID + ); + }); + + it('should resolve with waitingForConnectionSharingSession', async () => { + const testSharingSession = new Promise((resolve) => + resolve(({ ab: 'ba' } as unknown) as SharingSession) + ); + sharingSessionService.waitWhileUserIsNotCreated = jest + .fn() + .mockImplementation(() => { + return new Promise((resolve) => resolve(undefined)); + }); + sharingSessionService.createNewSharingSession = () => + testSharingSession; + + const res = await sharingSessionService.createWaitingForConnectionSharingSession( + '234' + ); + + expect(res).toBe(await testSharingSession); + }); + }); + }); + + describe('when changeSharingSessionStatusToSharing is called', () => { + it('should change passed sharingSession status to SHARING', () => { + const testSharingSession = ({ + status: 'dummystatus', + } as unknown) as SharingSession; + + sharingSessionService.changeSharingSessionStatusToSharing( + testSharingSession + ); + + expect(testSharingSession.status).toBe(SharingSessionStatusEnum.SHARING); + }); + }); + + describe('when waitWhileUserIsNotCreated is called', () => { + it('should wait until user is created then call clearInterval', () => { + const testUser = { + username: 'string', + privateKey: 'string', + publicKey: 'string', + }; + sharingSessionService.waitWhileUserIsNotCreated(); + + expect(setInterval).toHaveBeenCalledTimes(2); + expect(clearInterval).toHaveBeenCalledTimes(0); + + sharingSessionService.user = testUser; + + jest.advanceTimersByTime(10000); + + expect(setInterval).toHaveBeenCalledTimes(2); + expect(clearInterval).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/features/SharingSessionsService/index.ts b/app/features/SharingSessionService/index.ts similarity index 89% rename from app/features/SharingSessionsService/index.ts rename to app/features/SharingSessionService/index.ts index 04f706a..4266a34 100644 --- a/app/features/SharingSessionsService/index.ts +++ b/app/features/SharingSessionService/index.ts @@ -51,7 +51,7 @@ export default class SharingSessionService { createUser(): Promise { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { - if (process.env.RUN_MODE === 'test') resolve(); + if (process.env.RUN_MODE === 'test') resolve(undefined); const username = uuid.v4(); const encryptDecryptKeys = await this.crypto.createEncryptDecryptKeys(); @@ -67,14 +67,14 @@ export default class SharingSessionService { privateKey: exportedEncryptDecryptPrivateKey, publicKey: exportedEncryptDecryptPublicKey, }; - resolve(); + resolve(undefined); }); } createWaitingForConnectionSharingSession(roomID?: string) { return new Promise((resolve) => { - return this.waitWhileUserIsNotCreated().then(() => { - this.waitingForConnectionSharingSession = this.createNewSharingSession( + return this.waitWhileUserIsNotCreated().then(async () => { + this.waitingForConnectionSharingSession = await this.createNewSharingSession( roomID || '' ); resolve(this.waitingForConnectionSharingSession); @@ -83,8 +83,9 @@ export default class SharingSessionService { }); } - createNewSharingSession(_roomID: string): SharingSession { - const roomID = _roomID || this.roomIDService.getSimpleAvailableRoomID(); + async createNewSharingSession(_roomID: string): Promise { + const roomID = + _roomID || (await this.roomIDService.getSimpleAvailableRoomID()); this.roomIDService.markRoomIDAsTaken(roomID); const sharingSession = new SharingSession( roomID, @@ -119,7 +120,7 @@ export default class SharingSessionService { return new Promise((resolve) => { const currentInterval = setInterval(() => { if (this.user !== null) { - resolve(); + resolve(undefined); clearInterval(currentInterval); } }, 1000); diff --git a/app/features/SharingSessionsService/SharingSession.spec.ts b/app/features/SharingSessionsService/SharingSession.spec.ts deleted file mode 100644 index 92c0b04..0000000 --- a/app/features/SharingSessionsService/SharingSession.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import SharingSession from './SharingSession'; -import SharingSessionStatusEnum from './SharingSessionStatusEnum'; -import SharingType from './SharingTypeEnum'; - -jest.useFakeTimers(); - -describe('SharingSession unit tests', () => { - let sharingSession: SharingSession; - - beforeEach(() => { - sharingSession = new SharingSession( - '1234', - { - username: '', - privateKey: '', - publicKey: '', - }, - { - // @ts-ignore: fine here - createPeerConnectionHelperRenderer: () => { - return { - webContents: { - on: () => {}, - toggleDevTools: () => {}, - }, - }; - }, - }, - '', - '' - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('when new SahringSession() is created', () => { - it('should create new SharingSession with id', () => { - expect(sharingSession.id).toBeTruthy(); - }); - - it('should crete new SharingSession with deviceID equal to "" ', () => { - expect(sharingSession.deviceID).toBe(''); - }); - - it('should create new SharingSession with sharingType equal to NOT_SET', () => { - expect(sharingSession.sharingType).toBe(SharingType.NOT_SET); - }); - - it('should create new SharingSession with sharingStream set to null', () => { - expect(sharingSession.sharingStream).toBe(null); - }); - - it('should create new SharingSession with roomID', () => { - expect(sharingSession.roomID).toBeTruthy(); - }); - - it('should create new SharingSession with connectedDeviceAt set to null', () => { - expect(sharingSession.connectedDeviceAt).toBe(null); - }); - - it('should create new SharingSession with sharingStartedAt set to null', () => { - expect(sharingSession.sharingStartedAt).toBe(null); - }); - - it('should create new SharingSession with status set to NOT_CONNECTED', () => { - expect(sharingSession.status).toBe( - SharingSessionStatusEnum.NOT_CONNECTED - ); - }); - - it('should create new SharingSession with statusChangeListeners.length to be 0', () => { - expect(sharingSession.statusChangeListeners.length).toBe(0); - }); - }); - - describe('when addStatusChangeListener is called', () => { - it('should have statusChangeListeners.length of 1', () => { - sharingSession.addStatusChangeListener(() => {}); - - expect(sharingSession.statusChangeListeners.length).toBe(1); - }); - }); - - describe('when notifyStatusChangeListeners is called', () => { - it('should invoke all statusChangeListeners', async () => { - const mockStatusChangeListener1 = jest.fn(); - const mockStatusChangeListener2 = jest.fn(); - sharingSession.addStatusChangeListener(mockStatusChangeListener1); - sharingSession.addStatusChangeListener(mockStatusChangeListener2); - - await sharingSession.notifyStatusChangeListeners(); - - expect(mockStatusChangeListener1).toBeCalled(); - expect(mockStatusChangeListener2).toBeCalled(); - }); - }); - - describe('when setStatus is called', () => { - it('should invoke notifyStatusChangeListeners', async () => { - const mockNotifyStatusChangeListeners = jest.fn(); - sharingSession.notifyStatusChangeListeners = mockNotifyStatusChangeListeners; - - sharingSession.setStatus(SharingSessionStatusEnum.CONNECTED); - - expect(mockNotifyStatusChangeListeners).toBeCalled(); - }); - }); - - describe('when updateStatus is called with SharingSessionStatus argument', () => { - it('should set SharingSession.status as passed in updateStatus argument', async () => { - sharingSession.setStatus(SharingSessionStatusEnum.CONNECTED); - - expect(sharingSession.status).toBe(SharingSessionStatusEnum.CONNECTED); - - sharingSession.setStatus(SharingSessionStatusEnum.SHARING); - - expect(sharingSession.status).toBe(SharingSessionStatusEnum.SHARING); - }); - }); - - describe('when setDeviceID is called with deviceID argument', () => { - it('should set SharingSession.deviceID as in setDeviceID passed argument', async () => { - const testDeviceID = '8989'; - sharingSession.setDeviceID(testDeviceID); - - expect(sharingSession.deviceID).toBe(testDeviceID); - }); - }); -}); diff --git a/app/features/SharingSessionsService/SharingSessionService.spec.ts b/app/features/SharingSessionsService/SharingSessionService.spec.ts deleted file mode 100644 index c057675..0000000 --- a/app/features/SharingSessionsService/SharingSessionService.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import SharingSessionStatusEnum from './SharingSessionStatusEnum'; -import SharingSession from './SharingSession'; -import SharingSessionService from '.'; -import RoomIDService from '../../server/RoomIDService'; -import ConnectedDevicesService from '../ConnectedDevicesService'; -import PeerConnectionHelperRendererService from '../PeerConnectionHelperRendererService'; - -// this may look as an ugly mock, but hey, this works! and don't forget that it is a unit test -// why do we make it like that ? bacuse jest doesnt allow ex. -// duplicated __mock__/electron in different subfolders of the project, so.. better do mainual mock in a test file itself -// jest bug reference on duplicated mocks found: https://github.com/facebook/jest/issues/2070 -// it is a bad design of jest itself by default, so this is the best workaround, simply by making manual mock in this way: -jest.mock('../PeerConnectionHelperRendererService', () => { - return jest.fn().mockImplementation(() => { - return { - createPeerConnectionHelper: () => { - return { - webContents: { - on: () => {}, - toggleDevTools: () => {}, - }, - }; - }, - }; - }); -}); - -jest.useFakeTimers(); - -describe('SharingSessionService unit tests', () => { - let sharingSessionService: SharingSessionService; - - beforeEach(() => { - sharingSessionService = new SharingSessionService( - new RoomIDService(), - new ConnectedDevicesService(), - new PeerConnectionHelperRendererService('') - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('when new SharingSessionService() is created', () => { - it('should have empty sharingSessions Map', () => { - expect(sharingSessionService.sharingSessions.size).toBe(0); - }); - - it('should have waitingForConnectionSharingSession set to null', () => { - expect(sharingSessionService.waitingForConnectionSharingSession).toBe( - null - ); - }); - - it('should have pollForInactiveSessions be called', () => { - const backup = SharingSessionService.prototype.pollForInactiveSessions; - - const mockPollForInactiveSessions = jest.fn(); - jest - .spyOn(SharingSessionService.prototype, 'pollForInactiveSessions') - .mockImplementation(mockPollForInactiveSessions); - - // eslint-disable-next-line no-new - new SharingSessionService( - new RoomIDService(), - new ConnectedDevicesService(), - new PeerConnectionHelperRendererService('') - ); - - jest.advanceTimersByTime(1000 * 60 * 60 * 30); // thirty hours later - - expect(mockPollForInactiveSessions).toBeCalled(); - - SharingSessionService.prototype.pollForInactiveSessions = backup; - }); - }); - - describe('when createNewSharingSession is called', () => { - it('should have sharingSessions Map with size equal to 1', async () => { - await sharingSessionService.createNewSharingSession(''); - - expect(sharingSessionService.sharingSessions.size).toBe(1); - }); - - it('should have returned SharingSession object', async () => { - expect(sharingSessionService.createNewSharingSession('')).toBeInstanceOf( - SharingSession - ); - - const sharingSession = await sharingSessionService.createNewSharingSession( - '' - ); - expect(sharingSession).toBeInstanceOf(SharingSession); - }); - }); - - describe('when pollForInactiveSessions is called', () => { - it('should have removed SharingSession with status ERROR from sharingSessions Map', async () => { - const testSharingSession = await sharingSessionService.createNewSharingSession( - '' - ); - testSharingSession.status = SharingSessionStatusEnum.ERROR; - - sharingSessionService.pollForInactiveSessions(); - - expect(sharingSessionService.sharingSessions.size).toBe(0); - }); - }); -}); diff --git a/app/features/counter/Counter.css b/app/features/counter/Counter.css deleted file mode 100644 index e799c1c..0000000 --- a/app/features/counter/Counter.css +++ /dev/null @@ -1,37 +0,0 @@ -.backButton { - position: absolute; -} - -.counter { - position: absolute !important; - top: 30% !important; - left: 45% !important; - font-size: 10rem !important; - font-weight: bold !important; - letter-spacing: -0.025em !important; -} - -.btnGroup { - position: relative; - top: 500px; - width: 480px; - margin: 0 auto; -} - -.btn { - font-size: 1.6rem; - font-weight: bold; - background-color: #fff; - border-radius: 50%; - margin: 10px; - width: 100px; - height: 100px; - opacity: 0.7; - cursor: pointer; - font-family: Arial, Helvetica, Helvetica Neue, sans-serif; -} - -.btn:hover { - color: white; - background-color: rgba(0, 0, 0, 0.5); -} diff --git a/app/features/counter/Counter.spec.tsx b/app/features/counter/Counter.spec.tsx deleted file mode 100644 index 60f0022..0000000 --- a/app/features/counter/Counter.spec.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* eslint react/jsx-props-no-spreading: off, @typescript-eslint/ban-ts-comment: off */ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import { BrowserRouter as Router } from 'react-router-dom'; -import renderer from 'react-test-renderer'; -import { Provider } from 'react-redux'; -import { configureStore } from '@reduxjs/toolkit'; -import Counter from './Counter'; -import * as counterSlice from './counterSlice'; - -Enzyme.configure({ adapter: new Adapter() }); -jest.useFakeTimers(); - -function setup( - preloadedState: { counter: { value: number } } = { counter: { value: 1 } } -) { - const store = configureStore({ - reducer: { counter: counterSlice.default }, - preloadedState, - }); - - const getWrapper = () => - mount( - - - - - - ); - const component = getWrapper(); - return { - store, - component, - buttons: component.find('button'), - p: component.find('.counter'), - }; -} - -describe('Counter component', () => { - it('should should display count', () => { - const { p } = setup(); - expect(p.text()).toMatch(/^1$/); - }); - - it('should 2 button should call increment', () => { - const { buttons } = setup(); - const incrementSpy = jest.spyOn(counterSlice, 'increment'); - - buttons.at(1).simulate('click'); - expect(incrementSpy).toBeCalled(); - incrementSpy.mockRestore(); - }); - - it('should match exact snapshot', () => { - const { store } = setup(); - const tree = renderer - .create( - - - - - - ) - .toJSON(); - - expect(tree).toMatchSnapshot(); - }); - - it('should 3 button should call decrement', () => { - const { buttons } = setup(); - const decrementSyp = jest.spyOn(counterSlice, 'decrement'); - buttons.at(2).simulate('click'); - expect(decrementSyp).toBeCalled(); - decrementSyp.mockRestore(); - }); - - it('should 3 button should call incrementIfOdd', () => { - const { buttons } = setup(); - const incrementIfOdd = jest.spyOn(counterSlice, 'incrementIfOdd'); - buttons.at(3).simulate('click'); - expect(incrementIfOdd).toBeCalled(); - incrementIfOdd.mockRestore(); - }); - - it('should 4 button should call incrementAsync', () => { - const { buttons } = setup(); - const incrementAsync = jest.spyOn(counterSlice, 'incrementAsync'); - buttons.at(4).simulate('click'); - expect(incrementAsync).toBeCalled(); - incrementAsync.mockRestore(); - }); - - it('should display updated count after increment button click', () => { - const { buttons, p } = setup(); - buttons.at(1).simulate('click'); - expect(p.text()).toMatch(/^2$/); - }); - - it('should display updated count after decrement button click', () => { - const { buttons, p } = setup(); - buttons.at(2).simulate('click'); - expect(p.text()).toMatch(/^0$/); - }); - - it('shouldnt change if even and if odd button clicked', () => { - const { buttons, p } = setup({ counter: { value: 2 } }); - buttons.at(3).simulate('click'); - expect(p.text()).toMatch(/^2$/); - }); - - it('should change if odd and if odd button clicked', () => { - const { buttons, p } = setup({ counter: { value: 1 } }); - buttons.at(3).simulate('click'); - expect(p.text()).toMatch(/^2$/); - }); -}); - -describe('Test counter actions', () => { - it('should not call incrementAsync before timer', () => { - const fn = counterSlice.incrementAsync(1000); - expect(fn).toBeInstanceOf(Function); - const dispatch = jest.fn(); - // @ts-ignore - fn(dispatch); - jest.advanceTimersByTime(500); - expect(dispatch).not.toBeCalled(); - }); - - it('should call incrementAsync after timer', () => { - const fn = counterSlice.incrementAsync(1000); - expect(fn).toBeInstanceOf(Function); - const dispatch = jest.fn(); - // @ts-ignore - fn(dispatch); - jest.advanceTimersByTime(1001); - expect(dispatch).toBeCalled(); - }); -}); diff --git a/app/features/counter/Counter.tsx b/app/features/counter/Counter.tsx deleted file mode 100644 index f6027f5..0000000 --- a/app/features/counter/Counter.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { Link } from 'react-router-dom'; -import { Button } from '@blueprintjs/core'; -import { Grid } from 'react-flexbox-grid'; -import styles from './Counter.css'; -import routes from '../../constants/routes.json'; -import { - increment, - decrement, - incrementIfOdd, - incrementAsync, - selectCount, -} from './counterSlice'; - -export default function Counter() { - const dispatch = useDispatch(); - const value = useSelector(selectCount); - return ( - -
-
- -
-

- {value} -

-
- - - - -
-
- ); -} diff --git a/app/features/counter/__snapshots__/Counter.spec.tsx.snap b/app/features/counter/__snapshots__/Counter.spec.tsx.snap deleted file mode 100644 index 2334b9b..0000000 --- a/app/features/counter/__snapshots__/Counter.spec.tsx.snap +++ /dev/null @@ -1,126 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Counter component should match exact snapshot 1`] = ` -
- -

- 1 -

-
- - - - -
-
-`; diff --git a/app/features/counter/__snapshots__/counter.spec.ts.snap b/app/features/counter/__snapshots__/counter.spec.ts.snap deleted file mode 100644 index bb6dd2c..0000000 --- a/app/features/counter/__snapshots__/counter.spec.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`reducers counter should handle DECREMENT_COUNTER 1`] = ` -Object { - "value": 0, -} -`; - -exports[`reducers counter should handle INCREMENT_COUNTER 1`] = ` -Object { - "value": 2, -} -`; - -exports[`reducers counter should handle initial state 1`] = ` -Object { - "value": 0, -} -`; - -exports[`reducers counter should handle unknown action type 1`] = ` -Object { - "value": 1, -} -`; diff --git a/app/features/counter/counter.spec.ts b/app/features/counter/counter.spec.ts deleted file mode 100644 index d551d59..0000000 --- a/app/features/counter/counter.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AnyAction } from 'redux'; -import counterReducer, { increment, decrement } from './counterSlice'; - -describe('reducers', () => { - describe('counter', () => { - it('should handle initial state', () => { - expect(counterReducer(undefined, {} as AnyAction)).toMatchSnapshot(); - }); - - it('should handle INCREMENT_COUNTER', () => { - expect( - counterReducer({ value: 1 }, { type: increment }) - ).toMatchSnapshot(); - }); - - it('should handle DECREMENT_COUNTER', () => { - expect( - counterReducer({ value: 1 }, { type: decrement }) - ).toMatchSnapshot(); - }); - - it('should handle unknown action type', () => { - expect( - counterReducer({ value: 1 }, { type: 'unknown' }) - ).toMatchSnapshot(); - }); - }); -}); diff --git a/app/features/counter/counterSlice.ts b/app/features/counter/counterSlice.ts deleted file mode 100644 index 385d180..0000000 --- a/app/features/counter/counterSlice.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; -// eslint-disable-next-line import/no-cycle -import { AppThunk, RootState } from '../../store'; - -const counterSlice = createSlice({ - name: 'counter', - initialState: { value: 0 }, - reducers: { - increment: (state) => { - state.value += 1; - }, - decrement: (state) => { - state.value -= 1; - }, - }, -}); - -export const { increment, decrement } = counterSlice.actions; - -export const incrementIfOdd = (): AppThunk => { - return (dispatch, getState) => { - const state = getState(); - if (state.counter.value % 2 === 0) { - return; - } - dispatch(increment()); - }; -}; - -export const incrementAsync = (delay = 1000): AppThunk => (dispatch) => { - setTimeout(() => { - dispatch(increment()); - }, delay); -}; - -export default counterSlice.reducer; - -export const selectCount = (state: RootState) => state.counter.value; diff --git a/app/main.dev.spec.ts b/app/main.dev.spec.ts new file mode 100644 index 0000000..885b51f --- /dev/null +++ b/app/main.dev.spec.ts @@ -0,0 +1,824 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +import { BrowserWindow, app, ipcMain, screen } from 'electron'; +import settings from 'electron-settings'; +import DeskreenApp from './main.dev'; +import initGlobals from './utils/mainProcessHelpers/initGlobals'; +import signalingServer from './server'; +import MenuBuilder from './menu'; +import i18n from './configs/i18next.config'; +import getDeskreenGlobal from './utils/mainProcessHelpers/getDeskreenGlobal'; +import ConnectedDevicesService from './features/ConnectedDevicesService'; +import SharingSessionService from './features/SharingSessionService'; +import RendererWebrtcHelpersService from './features/PeerConnectionHelperRendererService'; +import installExtensions from './utils/installExtensions'; + +const sourceMapSupport = require('source-map-support'); +const electronDebug = require('electron-debug'); +const electronDevToolsInstaller = require('electron-devtools-installer'); + +const TEST_SIGNALING_SERVER_PORT = '4343'; +const TEST_DISPLAY_ID = 'd1'; +const TEST_DISPLAY_SIZE = { width: 600, height: 400 }; +const TEST_SCREEN_GET_ALL_DISPLAYS_RESULT = [ + { id: 'd1', size: { width: 600, height: 400 } }, + { id: 'd2' }, + { id: 'd3' }, +]; +const TEST_CONNECTED_DEVICES_SERVICE = ({ + sdf: 'fda', +} as unknown) as ConnectedDevicesService; +const TEST_ROOM_ID_SERVICE = ({ + a223: '2g2g', +} as unknown) as ConnectedDevicesService; +// const TEST_SHARING_SESSIONS = [ +// { denyConnectionForPartner: jest.fn(), destroy: jest.fn() }, +// { denyConnectionForPartner: jest.fn(), destroy: jest.fn() }, +// ]; +const testMapSharingSessions = new Map(); +testMapSharingSessions.set('1', { + denyConnectionForPartner: jest.fn(), + destroy: jest.fn(), +}); +testMapSharingSessions.set('2', { + denyConnectionForPartner: jest.fn(), + destroy: jest.fn(), +}); +const TEST_SHARING_SESSIONS_SERVICE = ({ + waitingForConnectionSharingSession: '2342a', + sharingSessions: testMapSharingSessions, +} as unknown) as SharingSessionService; +const testMapHelpers = new Map(); +testMapHelpers.set('1', { close: jest.fn() }); +testMapHelpers.set('2', { close: jest.fn() }); +const TEST_RENDERER_WEBRTC_HELPERS_SERVICE = ({ + helpers: testMapHelpers, +} as unknown) as RendererWebrtcHelpersService; +const mockGlobal = { + connectedDevicesService: TEST_CONNECTED_DEVICES_SERVICE, + roomIDService: TEST_ROOM_ID_SERVICE, + sharingSessionService: TEST_SHARING_SESSIONS_SERVICE, + rendererWebrtcHelpersService: TEST_RENDERER_WEBRTC_HELPERS_SERVICE, +}; + +jest.useFakeTimers(); + +jest.mock('./utils/installExtensions'); +jest.mock('./utils/AppUpdater'); +jest.mock('./main.dev', () => { + return { + __esModule: true, // this property makes it work + default: jest.requireActual('./main.dev').default, + }; +}); +jest.mock('./utils/mainProcessHelpers/getDeskreenGlobal'); +jest.mock('./utils/mainProcessHelpers/initGlobals'); +jest.mock('electron', () => { + return { + app: { + quit: jest.fn(), + on: jest.fn(), + getName: jest.fn(), + getVersion: jest.fn(), + commandLine: { + appendSwitch: jest.fn(), + }, + whenReady: jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(undefined))), + }, + ipcMain: { + handle: jest.fn(), + on: jest.fn(), + }, + screen: { + getAllDisplays: jest + .fn() + .mockReturnValue(TEST_SCREEN_GET_ALL_DISPLAYS_RESULT), + }, + BrowserWindow: jest.fn().mockReturnValue({ + loadURL: jest.fn(), + on: jest.fn(), + webContents: { + on: jest.fn(), + toggleDevTools: jest.fn(), + }, + minimize: jest.fn(), + show: jest.fn(), + focus: jest.fn(), + }), + }; +}); +jest.mock('./server', () => { + return { + start: jest.fn(), + port: TEST_SIGNALING_SERVER_PORT, + }; +}); +jest.mock('source-map-support', () => { + return { + install: jest.fn(), + }; +}); +jest.mock('electron-debug'); +jest.mock('electron-devtools-installer', () => { + return { + default: jest.fn(), + REACT_DEVELOPER_TOOLS: 'REACT_DEVELOPER_TOOLS', + REDUX_DEVTOOLS: 'REDUX_DEVTOOLS', + }; +}); +jest.mock('./configs/i18next.config', () => { + return { + on: jest.fn(), + changeLanguage: jest.fn(), + off: jest.fn(), + language: 'ua', + }; +}); +jest.mock('./menu'); +jest.mock('electron-settings', () => { + return { + set: jest.fn(), + }; +}); + +describe('app main.dev tests', () => { + let testApp: DeskreenApp; + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + // @ts-ignore + MenuBuilder.mockClear(); + // @ts-ignore + installExtensions.mockClear(); + + testApp = new DeskreenApp(); + }); + + describe('when DeskreenApp created properly', () => { + describe('when .start() was called', () => { + it('should call initGlobals', () => { + testApp.start(); + + expect(initGlobals).toBeCalled(); + }); + + it('should call signalingServer.start()', () => { + testApp.start(); + + expect(signalingServer.start).toBeCalled(); + }); + + it('should call .initElectronAppObject()', () => { + testApp.initElectronAppObject = jest.fn(); + + testApp.start(); + + expect(testApp.initElectronAppObject).toBeCalled(); + }); + + it('should call .initIpcMain()', () => { + testApp.initIpcMain = jest.fn(); + + testApp.start(); + + expect(testApp.initIpcMain).toBeCalled(); + }); + + describe('when initElectronAppObject was called', () => { + it('should set app.on("window-all-closed" listener', () => { + testApp.initElectronAppObject(); + + expect(app.on).toHaveBeenCalledWith( + 'window-all-closed', + expect.anything() + ); + }); + + it('should call app.commandLine.appendSwitch with "webrtc-max-cpu-consumption-percentage","100"', () => { + testApp.initElectronAppObject(); + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'webrtc-max-cpu-consumption-percentage', + '100' + ); + }); + + describe('when process.env.E2E_BUILD !== "true"', () => { + it('should set app.on("ready" listener', () => { + const processEnvBackup = process.env.E2E_BUILD; + process.env.E2E_BUILD = 'false'; + + testApp.initElectronAppObject(); + + expect(app.on).toHaveBeenCalledWith('ready', expect.anything()); + + process.env.E2E_BUILD = processEnvBackup; + }); + }); + + describe('when process.env.E2E_BUILD === "true"', () => { + it('should set app.on("ready" listener', () => { + const processEnvBackup = process.env.E2E_BUILD; + process.env.E2E_BUILD = 'true'; + + testApp.initElectronAppObject(); + + expect(app.whenReady).toHaveBeenCalled(); + + process.env.E2E_BUILD = processEnvBackup; + }); + }); + + describe('when app.on("window-all-closed" event occured', () => { + describe('when running on NOT darwin platform', () => { + it('should call app.quit()', () => { + const processBackup = process; + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = { + ...processBackup, + platform: 'linux', + }; + + testApp.initElectronAppObject(); + + // @ts-ignore + const callback = app.on.mock.calls[0][1]; + callback(); + + expect(app.quit).toBeCalled(); + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = processBackup; + }); + }); + + describe('when running on darwin platform', () => { + it('should NOT call app.quit()', () => { + const processBackup = process; + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = { + ...processBackup, + platform: 'darwin', + }; + + testApp.initElectronAppObject(); + + // @ts-ignore + const callback = app.on.mock.calls[0][1]; + callback(); + + expect(app.quit).not.toBeCalled(); + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = processBackup; + }); + }); + }); + + describe('when app.on("activate" event occured', () => { + it('should call .createWindow if mainWindow is null', () => { + testApp.mainWindow = null; + testApp.createWindow = jest.fn(); + + testApp.initElectronAppObject(); + + // @ts-ignore + const callback = app.on.mock.calls[2][1]; + callback({ preventDefault: () => {} }); + + expect(testApp.createWindow).toBeCalled(); + }); + + it('should NOT call .createWindow if mainWindow is not null', () => { + testApp.mainWindow = ({ + asdf: 'agasg', + } as unknown) as BrowserWindow; + testApp.createWindow = jest.fn(); + + testApp.initElectronAppObject(); + + // @ts-ignore + const callback = app.on.mock.calls[2][1]; + callback({ preventDefault: () => {} }); + + expect(testApp.createWindow).not.toBeCalled(); + }); + }); + }); + + describe('when initIpcMain was called', () => { + it('should set ipcMain.on("client-changed-language" listener', () => { + testApp.initIpcMain(); + + expect(ipcMain.on).toHaveBeenCalledWith( + 'client-changed-language', + expect.anything() + ); + }); + + it('should set ipcMain.handle("get-signaling-server-port" listener', () => { + testApp.initIpcMain(); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-signaling-server-port', + expect.anything() + ); + }); + + it('should set ipcMain.handle("get-all-displays" listener', () => { + testApp.initIpcMain(); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-all-displays', + expect.anything() + ); + }); + + it('should set ipcMain.handle("get-display-size-by-display-id" listener', () => { + testApp.initIpcMain(); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-display-size-by-display-id', + expect.anything() + ); + }); + + it('should set ipcMain.handle("main-window-onbeforeunload" listener', () => { + testApp.initIpcMain(); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'main-window-onbeforeunload', + expect.anything() + ); + }); + + describe('when ipcMain.on("client-changed-language" callback was called', () => { + it('should call i18n.changeLanguage and settings.set("appLanguage", newLangCode)', async () => { + const testNewLang = 'bz'; + + testApp.initIpcMain(); + + // @ts-ignore + const callback = ipcMain.on.mock.calls[0][1]; + await callback(undefined, testNewLang); + + expect(i18n.changeLanguage).toHaveBeenCalledWith(testNewLang); + expect(settings.set).toHaveBeenCalledWith( + 'appLanguage', + testNewLang + ); + }); + }); + + describe('when ipcMain.on("get-signaling-server-port" callback was called', () => { + describe('when main window is defined', () => { + it('should send a signaling server port to main window', () => { + testApp.mainWindow = ({ + webContents: { send: jest.fn() }, + } as unknown) as BrowserWindow; + + testApp.initIpcMain(); + + // @ts-ignore + const callback = ipcMain.handle.mock.calls[0][1]; + callback(); + + expect(testApp.mainWindow.webContents.send).toHaveBeenCalledWith( + 'sending-port-from-main', + TEST_SIGNALING_SERVER_PORT + ); + }); + }); + }); + + describe('when ipcMain.on("get-all-displays" callback was called', () => { + it('should return screen.getAllDisplays() result', () => { + testApp.initIpcMain(); + + // @ts-ignore + const callback = ipcMain.handle.mock.calls[1][1]; + const res = callback(); + + expect(res).toBe(TEST_SCREEN_GET_ALL_DISPLAYS_RESULT); + expect(screen.getAllDisplays).toBeCalled(); + }); + }); + + describe('when ipcMain.on("get-display-size-by-display-id" callback was called', () => { + describe('when displayID exists in screen.getAllDisplays() result', () => { + it('should return display size as expected', () => { + testApp.initIpcMain(); + + // @ts-ignore + const callback = ipcMain.handle.mock.calls[2][1]; + const res = callback(undefined, TEST_DISPLAY_ID); + + expect(res).toEqual(TEST_DISPLAY_SIZE); + }); + }); + + describe('when displayID NOT exist in screen.getAllDisplays() result', () => { + it('should return undefined expected', () => { + testApp.initIpcMain(); + + // @ts-ignore + const callback = ipcMain.handle.mock.calls[2][1]; + const res = callback(undefined, 'dagaw22ds'); + + expect(res).toBe(undefined); + }); + }); + }); + + describe('when ipcMain.on("main-window-onbeforeunload" callback was called', () => { + it('should reset globals', () => { + // @ts-ignore + getDeskreenGlobal.mockReturnValue(mockGlobal); + + testApp.initIpcMain(); + + // @ts-ignore + const callback = ipcMain.handle.mock.calls[3][1]; + callback(); + + const deskreenGlobal = getDeskreenGlobal(); + + expect(deskreenGlobal.connectedDevicesService).not.toBe( + TEST_CONNECTED_DEVICES_SERVICE + ); + expect(deskreenGlobal.roomIDService).not.toBe(TEST_ROOM_ID_SERVICE); + testMapSharingSessions.forEach((s) => { + expect(s.denyConnectionForPartner).toBeCalled(); + expect(s.destroy).toBeCalled(); + }); + testMapHelpers.forEach((s) => { + expect(s.close).toBeCalled(); + }); + + expect( + deskreenGlobal.sharingSessionService + .waitingForConnectionSharingSession + ).toBe(null); + expect(testMapHelpers.size).toBe(0); + expect(testMapSharingSessions.size).toBe(0); + }); + }); + + describe('when createWindow is called', () => { + describe('when in dev environment', () => { + it('should call installExtensions', async () => { + // @ts-ignore + // installExtensions = jest.fn(); + const processEnvNodeEnvBackup = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + await testApp.createWindow(); + + expect(installExtensions).toBeCalledTimes(1); + + process.env.NODE_ENV = processEnvNodeEnvBackup; + + const processDebugProdBackup = process.env.DEBUG_PROD; + process.env.DEBUG_PROD = 'true'; + + await testApp.createWindow(); + + expect(installExtensions).toBeCalledTimes(2); + + process.env.DEBUG_PROD = processDebugProdBackup; + }); + }); + + describe('when mainWindow is created', () => { + it('should call .mainWindow.loadURL with proper parameter', () => { + testApp.createWindow(); + + expect(testApp.mainWindow?.loadURL).toHaveBeenCalledWith( + `file://${__dirname}/app.html` + ); + }); + + it('should set .mainWindow.webContents.on("did-finish-load"', () => { + testApp.createWindow(); + + expect(testApp.mainWindow?.webContents.on).toHaveBeenCalledWith( + 'did-finish-load', + expect.anything() + ); + }); + + describe('when process.env.NODE_ENV === "dev"', () => { + it('should call this.mainWindow.webContents.toggleDevTools', () => { + const backProcEnvNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'dev'; + + testApp.createWindow(); + + expect( + testApp.mainWindow?.webContents.toggleDevTools + ).toBeCalled(); + + process.env.NODE_ENV = backProcEnvNodeEnv; + }); + }); + + describe('when .mainWindow?.webContents.on("did-finish-load" callback called', () => { + describe('when mainWindow is not defined', () => { + it('should throw an error', () => { + testApp.createWindow(); + + const callback = + // @ts-ignore + testApp.mainWindow.webContents.on.mock.calls[0][1]; + + testApp.mainWindow = null; + + try { + callback(); + // eslint-disable-next-line jest/no-jasmine-globals + fail(); + } catch (e) { + // eslint-disable-next-line jest/no-try-expect + expect(e).toEqual(new Error('"mainWindow" is not defined')); + } + }); + }); + + describe('when process.env.START_MINIMIZED is defined', () => { + it('should call mainWindow.minimize', () => { + testApp.createWindow(); + const backProcessEnvStartMinimized = + process.env.START_MINIMIZED; + process.env.START_MINIMIZED = 'true'; + + const callback = + // @ts-ignore + testApp.mainWindow.webContents.on.mock.calls[0][1]; + + callback(); + + expect(testApp.mainWindow?.minimize).toBeCalled(); + + process.env.START_MINIMIZED = backProcessEnvStartMinimized; + }); + }); + + describe('when process.env.START_MINIMIZED is NOT defined', () => { + it('should call mainWindow.show and mainWindow.focus', () => { + testApp.createWindow(); + const backProcessEnvStartMinimized = + process.env.START_MINIMIZED; + process.env.START_MINIMIZED = 'false'; + + const callback = + // @ts-ignore + testApp.mainWindow.webContents.on.mock.calls[0][1]; + + callback(); + + expect(testApp.mainWindow?.show).toBeCalled(); + expect(testApp.mainWindow?.focus).toBeCalled(); + + process.env.START_MINIMIZED = backProcessEnvStartMinimized; + }); + }); + }); + + describe('when .mainWindow?.on("closed" callback called', () => { + it('should set main window to null', () => { + testApp.createWindow(); + const callback = + // @ts-ignore + testApp.mainWindow.on.mock.calls[0][1]; + + callback(); + + expect(testApp.mainWindow).toBeNull(); + }); + describe('when process.platform !== "darwin"', () => { + it('should call app.quit()', () => { + const processBackup = process; + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = { + ...processBackup, + platform: 'linux', + }; + testApp.createWindow(); + + const callback = + // @ts-ignore + testApp.mainWindow.on.mock.calls[0][1]; + + callback(); + + expect(app.quit).toBeCalled(); + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = processBackup; + }); + }); + + describe('when process.platform === "darwin"', () => { + it('should call app.quit()', () => { + const processBackup = process; + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = { + ...processBackup, + platform: 'darwin', + }; + testApp.createWindow(); + + const callback = + // @ts-ignore + testApp.mainWindow.on.mock.calls[0][1]; + + callback(); + + expect(app.quit).not.toBeCalled(); + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = processBackup; + }); + }); + }); + }); + }); + }); + + describe('when process.env.NODE_ENV === "production"', () => { + it('should call sourceMapSupport to be called when ', () => { + const envNodeEnvBackup = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + testApp.start(); + + expect(sourceMapSupport.install).toBeCalled(); + + process.env.NODE_ENV = envNodeEnvBackup; + }); + }); + + describe('when process.env.NODE_ENV === "development"', () => { + it('should call electron-debug ', () => { + const envNodeEnvBackup = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + testApp.start(); + + expect(electronDebug).toBeCalled(); + + process.env.NODE_ENV = envNodeEnvBackup; + }); + }); + + describe('when process.env.DEBUG_PROD === "true"', () => { + it('should call electron-debug ', () => { + const envDebugProdBackup = process.env.DEBUG_PROD; + process.env.DEBUG_PROD = 'true'; + + testApp.start(); + + expect(electronDebug).toBeCalled(); + + process.env.DEBUG_PROD = envDebugProdBackup; + }); + }); + }); + + describe('when .initI18n() was called', () => { + it('should init i18n object with .on("loaded" event', () => { + testApp.initI18n(); + + expect(i18n.on).toBeCalledWith('loaded', expect.anything()); + }); + + it('should init i18n object with .on("languageChanged" event', () => { + testApp.initI18n(); + + expect(i18n.on).toBeCalledWith('languageChanged', expect.anything()); + }); + + describe('when "loaded" event occured', () => { + it('should call changleLanguage("en") and i18n.off("loaded"', () => { + testApp.initI18n(); + // @ts-ignore + const callback = i18n.on.mock.calls[0][1]; + + callback(); + + expect(i18n.changeLanguage).toBeCalledWith('en'); + expect(i18n.off).toBeCalledWith('loaded'); + }); + }); + + describe('when "languageChanged" event occured', () => { + describe('when mainWindow is defined', () => { + it('should create new MenuBuilder', () => { + testApp.mainWindow = ({} as unknown) as BrowserWindow; + testApp.initI18n(); + // @ts-ignore + const callback = i18n.on.mock.calls[1][1]; + + callback(); + + expect(MenuBuilder).toHaveBeenCalledTimes(1); + expect(MenuBuilder).toHaveBeenCalledWith(testApp.mainWindow, i18n); + }); + + it('should call .buildMenu() of menuBuilder', () => { + testApp.mainWindow = ({} as unknown) as BrowserWindow; + testApp.initI18n(); + // @ts-ignore + const callback = i18n.on.mock.calls[1][1]; + + callback(); + + // @ts-ignore + const mockMenuBuilderInstance = MenuBuilder.mock.instances[0]; + + expect(mockMenuBuilderInstance.buildMenu).toBeCalled(); + }); + + it('should call setTimeout with callback and delay', () => { + testApp.mainWindow = ({} as unknown) as BrowserWindow; + testApp.initI18n(); + // @ts-ignore + const callback = i18n.on.mock.calls[1][1]; + + callback(); + + expect(setTimeout).toHaveBeenCalledWith(expect.anything(), 400); + }); + + describe('when setTimeout callback triggered after delay', () => { + describe('when should really change app lang', () => { + it('should call i18n.changeLanguage with passed language', () => { + const testLng = 'bg'; + testApp.mainWindow = ({} as unknown) as BrowserWindow; + testApp.initI18n(); + // @ts-ignore + const callback = i18n.on.mock.calls[1][1]; + callback(testLng); + // @ts-ignore + const timeoutCallback = setTimeout.mock.calls[0][0]; + + timeoutCallback(); + + expect(i18n.changeLanguage).toHaveBeenCalledWith(testLng); + }); + + it('should set "appLanguage" in electron-settings', async () => { + const testLng = 'bg'; + testApp.mainWindow = ({} as unknown) as BrowserWindow; + testApp.initI18n(); + // @ts-ignore + const callback = i18n.on.mock.calls[1][1]; + callback(testLng); + // @ts-ignore + const timeoutCallback = setTimeout.mock.calls[0][0]; + + await timeoutCallback(); + + expect(settings.set).toHaveBeenCalledWith( + 'appLanguage', + testLng + ); + }); + }); + }); + }); + }); + }); + }); + + describe('when installExtensions was called', () => { + it('should call electron-devtools-installer with "REACT_DEVELOPER_TOOLS" and "REDUX_DEVTOOLS"', async () => { + // @ts-ignore + installExtensions.mockImplementation( + jest.requireActual('./utils/installExtensions').default + ); + + await installExtensions(); + + expect(electronDevToolsInstaller.default).toBeCalledWith( + 'REDUX_DEVTOOLS', + !!process.env.UPGRADE_EXTENSIONS + ); + expect(electronDevToolsInstaller.default).toBeCalledWith( + 'REACT_DEVELOPER_TOOLS', + !!process.env.UPGRADE_EXTENSIONS + ); + }); + }); +}); diff --git a/app/main.dev.ts b/app/main.dev.ts index b94c497..5dddd00 100644 --- a/app/main.dev.ts +++ b/app/main.dev.ts @@ -12,210 +12,266 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import { Display } from 'electron/main'; import path from 'path'; -import { app, BrowserWindow, ipcMain, screen } from 'electron'; -import { autoUpdater } from 'electron-updater'; -import log from 'electron-log'; +import { app, BrowserWindow, ipcMain, screen, shell } from 'electron'; import settings from 'electron-settings'; import i18n from './configs/i18next.config'; import signalingServer from './server'; import MenuBuilder from './menu'; -import initGlobals from './mainProcessHelpers/initGlobals'; +import initGlobals from './utils/mainProcessHelpers/initGlobals'; import ConnectedDevicesService from './features/ConnectedDevicesService'; import RoomIDService from './server/RoomIDService'; -import SharingSession from './features/SharingSessionsService/SharingSession'; -import getDeskreenGlobal from './mainProcessHelpers/getDeskreenGlobal'; +import SharingSession from './features/SharingSessionService/SharingSession'; +import getDeskreenGlobal from './utils/mainProcessHelpers/getDeskreenGlobal'; +import AppUpdater from './utils/AppUpdater'; +import installExtensions from './utils/installExtensions'; +import getNewVersionTag from './utils/getNewVersionTag'; -initGlobals(__dirname); +const v4IPGetter = require('internal-ip').v4; -signalingServer.start(); +export default class DeskreenApp { + mainWindow: BrowserWindow | null = null; -export default class AppUpdater { - constructor() { - log.transports.file.level = 'info'; - autoUpdater.logger = log; - autoUpdater.checkForUpdatesAndNotify(); - } -} + menuBuilder: MenuBuilder | null = null; -let mainWindow: BrowserWindow | null = null; -let menuBuilder: MenuBuilder | null = null; + appVersion: string = app.getVersion(); -if (process.env.NODE_ENV === 'production') { - const sourceMapSupport = require('source-map-support'); - sourceMapSupport.install(); -} + latestVersion = ''; -if ( - process.env.NODE_ENV === 'development' || - process.env.DEBUG_PROD === 'true' -) { - require('electron-debug')(); -} - -const installExtensions = async () => { - const installer = require('electron-devtools-installer'); - const forceDownload = !!process.env.UPGRADE_EXTENSIONS; - const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS']; - - return Promise.all( - extensions.map((name) => installer.default(installer[name], forceDownload)) - ).catch(console.log); -}; - -const createWindow = async () => { - if ( - process.env.NODE_ENV === 'development' || - process.env.DEBUG_PROD === 'true' - ) { - await installExtensions(); - } - - mainWindow = new BrowserWindow({ - show: false, - width: 820, - height: 540, - minHeight: 400, - minWidth: 600, - titleBarStyle: 'hiddenInset', - useContentSize: true, - webPreferences: - (process.env.NODE_ENV === 'development' || - process.env.E2E_BUILD === 'true') && - process.env.ERB_SECURE !== 'true' - ? { - nodeIntegration: true, - enableRemoteModule: true, - } - : { - preload: path.join(__dirname, 'dist/mainWindow.renderer.prod.js'), - enableRemoteModule: true, - }, - }); - - mainWindow.loadURL(`file://${__dirname}/app.html`); - - // @TODO: Use 'ready-to-show' event - // https://github.com/electron/electron/blob/master/docs/api/browser-window.md#using-ready-to-show-event - mainWindow.webContents.on('did-finish-load', () => { - if (!mainWindow) { - throw new Error('"mainWindow" is not defined'); - } - if (process.env.START_MINIMIZED) { - mainWindow.minimize(); - } else { - mainWindow.show(); - mainWindow.focus(); - } - }); - - mainWindow.on('closed', () => { - mainWindow = null; - // TODO: when app will be set to auto start on login, this will be not required, - // TODO: the app will run until user didn't kill it in system tray - if (process.platform !== 'darwin') { - app.quit(); - } - }); - - // mainWindow.webContents.toggleDevTools(); - - menuBuilder = new MenuBuilder(mainWindow, i18n); - menuBuilder.buildMenu(); - - i18n.on('loaded', () => { - i18n.changeLanguage('en'); - i18n.off('loaded'); - }); - - i18n.on('languageChanged', (lng) => { - if (mainWindow === null) return; - menuBuilder = new MenuBuilder(mainWindow, i18n); - menuBuilder.buildMenu(); - setTimeout(async () => { - if (lng !== 'en' && i18n.language !== lng) { - i18n.changeLanguage(lng); - await settings.set('appLanguage', lng); + initElectronAppObject() { + /** + * Add event listeners... + */ + app.on('window-all-closed', () => { + // TODO: when app will be set to auto start on login, this will be not required, + // TODO: the app will run until user didn't kill it in system tray + // Respect the OSX convention of having the application in memory even + // after all windows have been closed + if (process.platform !== 'darwin') { + app.quit(); } - }, 400); - }); + }); - // Remove this if your app does not use auto updates - // eslint-disable-next-line - new AppUpdater(); -}; + if (process.env.E2E_BUILD === 'true') { + // eslint-disable-next-line promise/catch-or-return + app.whenReady().then(this.createWindow); + } else { + app.on('ready', async () => { + this.createWindow(); -/** - * Add event listeners... - */ + const { Notification } = require('electron'); -app.on('window-all-closed', () => { - // TODO: when app will be set to auto start on login, this will be not required, - // TODO: the app will run until user didn't kill it in system tray - // Respect the OSX convention of having the application in memory even - // after all windows have been closed - if (process.platform !== 'darwin') { - app.quit(); + const showNotification = () => { + const notification = { + title: 'Deskreen Update is Available!', + body: `Your current version is ${this.appVersion} Click to download new 1.0.1 updated version.`, + }; + const notificationInstance = new Notification(notification); + notificationInstance.show(); + notificationInstance.on('click', (event) => { + event.preventDefault(); // prevent the browser from focusing the Notification's tab + shell.openExternal('https://github.com/pavlobu/deskreen/releases/'); + }); + }; + + const newVersion = await getNewVersionTag(); + + if (newVersion !== '' && newVersion !== this.appVersion) { + this.latestVersion = newVersion; + showNotification(); + } + }); + } + + app.on('activate', (e) => { + e.preventDefault(); + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (this.mainWindow === null) { + this.createWindow(); + } + }); + + app.commandLine.appendSwitch( + 'webrtc-max-cpu-consumption-percentage', + '100' + ); } -}); -if (process.env.E2E_BUILD === 'true') { - // eslint-disable-next-line promise/catch-or-return - app.whenReady().then(createWindow); -} else { - app.on('ready', createWindow); + initIpcMain() { + ipcMain.on('client-changed-language', async (_, newLangCode) => { + i18n.changeLanguage(newLangCode); + await settings.set('appLanguage', newLangCode); + }); + + ipcMain.handle('get-signaling-server-port', () => { + if (this.mainWindow === null) return; + this.mainWindow.webContents.send( + 'sending-port-from-main', + signalingServer.port + ); + }); + + ipcMain.handle('get-all-displays', () => { + return screen.getAllDisplays(); + }); + + ipcMain.handle('get-display-size-by-display-id', (_, displayID: string) => { + const display = screen.getAllDisplays().find((d: Display) => { + return `${d.id}` === displayID; + }); + + if (display) { + return display.size; + } + return undefined; + }); + + ipcMain.handle('main-window-onbeforeunload', () => { + const deskreenGlobal = getDeskreenGlobal(); + deskreenGlobal.connectedDevicesService = new ConnectedDevicesService(); + deskreenGlobal.roomIDService = new RoomIDService(); + deskreenGlobal.sharingSessionService.sharingSessions.forEach( + (sharingSession: SharingSession) => { + sharingSession.denyConnectionForPartner(); + sharingSession.destroy(); + } + ); + + deskreenGlobal.rendererWebrtcHelpersService.helpers.forEach( + (helperWindow) => { + helperWindow.close(); + } + ); + + deskreenGlobal.sharingSessionService.waitingForConnectionSharingSession = null; + deskreenGlobal.rendererWebrtcHelpersService.helpers.clear(); + deskreenGlobal.sharingSessionService.sharingSessions.clear(); + }); + + ipcMain.handle('get-latest-version', () => { + return this.latestVersion; + }); + + ipcMain.handle('get-current-version', () => { + return this.appVersion; + }); + + ipcMain.handle('get-local-lan-ip', () => { + return process.env.RUN_MODE === 'dev' || + process.env.NODE_ENV === 'production' + ? v4IPGetter.sync() + : '255.255.255.255'; + }); + } + + async createWindow() { + if ( + process.env.NODE_ENV === 'development' || + process.env.DEBUG_PROD === 'true' + ) { + await installExtensions(); + } + + this.mainWindow = new BrowserWindow({ + show: false, + width: 820, + height: 540, + minHeight: 400, + minWidth: 600, + titleBarStyle: 'hiddenInset', + useContentSize: true, + webPreferences: + (process.env.NODE_ENV === 'development' || + process.env.E2E_BUILD === 'true') && + process.env.ERB_SECURE !== 'true' + ? { + nodeIntegration: true, + enableRemoteModule: true, + } + : { + preload: path.join(__dirname, 'dist/mainWindow.renderer.prod.js'), + enableRemoteModule: true, + }, + }); + + this.mainWindow.loadURL(`file://${__dirname}/app.html`); + + // @TODO: Use 'ready-to-show' event + // https://github.com/electron/electron/blob/master/docs/api/browser-window.md#using-ready-to-show-event + this.mainWindow.webContents.on('did-finish-load', () => { + if (!this.mainWindow) { + throw new Error('"mainWindow" is not defined'); + } + if (process.env.START_MINIMIZED === 'true') { + this.mainWindow.minimize(); + } else { + this.mainWindow.show(); + this.mainWindow.focus(); + } + }); + + this.mainWindow.on('closed', () => { + this.mainWindow = null; + // TODO: when app will be set to auto start on login, this will be not required, + // TODO: the app will run until user didn't kill it in system tray + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + if (process.env.NODE_ENV === 'dev') { + this.mainWindow.webContents.toggleDevTools(); + } + + this.menuBuilder = new MenuBuilder(this.mainWindow, i18n); + this.menuBuilder.buildMenu(); + + this.initI18n(); + + // Remove this if your app does not use auto updates + // eslint-disable-next-line + new AppUpdater(); + } + + initI18n() { + i18n.on('loaded', () => { + i18n.changeLanguage('en'); + i18n.off('loaded'); + }); + + i18n.on('languageChanged', (lng) => { + if (this.mainWindow === null) return; + this.menuBuilder = new MenuBuilder(this.mainWindow, i18n); + this.menuBuilder.buildMenu(); + setTimeout(async () => { + if (lng !== 'en' && i18n.language !== lng) { + i18n.changeLanguage(lng); + await settings.set('appLanguage', lng); + } + }, 400); + }); + } + + start() { + initGlobals(__dirname); + signalingServer.start(); + + if (process.env.NODE_ENV === 'production') { + const sourceMapSupport = require('source-map-support'); + sourceMapSupport.install(); + } + + if ( + process.env.NODE_ENV === 'development' || + process.env.DEBUG_PROD === 'true' + ) { + require('electron-debug')(); + } + + this.initElectronAppObject(); + this.initIpcMain(); + } } -app.on('activate', () => { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (mainWindow === null) createWindow(); -}); - -ipcMain.handle('get-signaling-server-port', () => { - if (mainWindow === null) return; - mainWindow.webContents.send('sending-port-from-main', signalingServer.port); -}); - -ipcMain.on('client-changed-language', async (_, newLangCode) => { - i18n.changeLanguage(newLangCode); - await settings.set('appLanguage', newLangCode); -}); - -ipcMain.handle('get-all-displays', () => { - return screen.getAllDisplays(); -}); - -ipcMain.handle('get-display-size-by-display-id', (_, displayID: string) => { - const display = screen.getAllDisplays().find((d: Display) => { - return `${d.id}` === displayID; - }); - - if (display) { - return display.size; - } - return undefined; -}); - -ipcMain.handle('main-window-onbeforeunload', () => { - const deskreenGlobal = getDeskreenGlobal(); - deskreenGlobal.connectedDevicesService = new ConnectedDevicesService(); - deskreenGlobal.roomIDService = new RoomIDService(); - deskreenGlobal.sharingSessionService.sharingSessions.forEach( - (sharingSession: SharingSession) => { - sharingSession.denyConnectionForPartner(); - sharingSession.destory(); - } - ); - - deskreenGlobal.rendererWebrtcHelpersService.helpers.forEach( - (helperWindow) => { - helperWindow.close(); - } - ); - - deskreenGlobal.sharingSessionService.waitingForConnectionSharingSession = null; - deskreenGlobal.rendererWebrtcHelpersService.helpers.clear(); - deskreenGlobal.sharingSessionService.sharingSessions.clear(); -}); - -app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100'); +const deskreenApp = new DeskreenApp(); +deskreenApp.start(); diff --git a/app/mainProcessHelpers/DeskreenGlobal.d.ts b/app/mainProcessHelpers/DeskreenGlobal.d.ts deleted file mode 100644 index 7037e8e..0000000 --- a/app/mainProcessHelpers/DeskreenGlobal.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import ConnectedDevicesService from '../features/ConnectedDevicesService'; -import SharingSessionService from '../features/SharingSessionsService'; -import RendererWebrtcHelpersService from '../features/PeerConnectionHelperRendererService'; -import RoomIDService from '../server/RoomIDService'; -import DesktopCapturerSources from '../features/DesktopCapturerSourcesService'; - -interface DeskreenGlobal { - appPath: string; - rendererWebrtcHelpersService: RendererWebrtcHelpersService; - roomIDService: RoomIDService; - connectedDevicesService: ConnectedDevicesService; - sharingSessionService: SharingSessionService; - desktopCapturerSourcesService: DesktopCapturerSources; -} diff --git a/app/menu.spec.ts b/app/menu.spec.ts new file mode 100644 index 0000000..b910eea --- /dev/null +++ b/app/menu.spec.ts @@ -0,0 +1,348 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable jest/expect-expect */ +import { + app, + BrowserWindow, + shell, + Menu, + MenuItemConstructorOptions, +} from 'electron'; +import signalingServer from './server'; +import MenuBuilder from './menu'; + +jest.useFakeTimers(); + +jest.mock('electron', () => { + return { + app: { + quit: jest.fn(), + }, + shell: { + openExternal: jest.fn(), + }, + Menu: { + buildFromTemplate: jest.fn().mockReturnValue({ + popup: jest.fn(), + }), + setApplicationMenu: jest.fn(), + }, + }; +}); + +jest.mock('./server', () => { + return { + stop: jest.fn(), + }; +}); + +describe('app menu MenyBuilder tests', () => { + let menuBuilder: MenuBuilder; + let testMainWindow: BrowserWindow; + let testI18n: any; + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + testMainWindow = ({ + webContents: { + on: jest.fn(), + inspectElement: jest.fn(), + reload: jest.fn(), + toggleDevTools: jest.fn(), + }, + setFullScreen: jest.fn(), + isFullScreen: jest.fn(), + inspectElement: jest.fn(), + } as unknown) as BrowserWindow; + + testI18n = { + t: jest.fn(), + language: 'en', + changeLanguage: jest.fn(), + }; + + menuBuilder = new MenuBuilder(testMainWindow, testI18n); + }); + + describe('when MenyBuilder created properly', () => { + describe('when setupDevelopmentEnvironment was called', () => { + it('should call .mainWindow.webContents.on("context-menu"', () => { + menuBuilder.setupDevelopmentEnvironment(); + + expect(testMainWindow.webContents.on).toBeCalledWith( + 'context-menu', + expect.anything() + ); + }); + + describe('when .mainWindow.webContents.on("context-menu" event callback triggered (eg. righti click on any UI element in main host app window)', () => { + it('should build menu with Inspect element on right click with proper coordinates', () => { + const testProps = { x: 123, y: 321 }; + menuBuilder.setupDevelopmentEnvironment(); + // @ts-ignore + const callback = testMainWindow.webContents.on.mock.calls[0][1]; + + callback(undefined, testProps); + + expect(Menu.buildFromTemplate).toBeCalledWith([ + { + label: 'Inspect element', + click: expect.anything(), + }, + ]); + + const inspectElementCallback = + // @ts-ignore + Menu.buildFromTemplate.mock.calls[0][0][0].click; + + inspectElementCallback(); + + expect(testMainWindow.webContents.inspectElement).toBeCalledWith( + testProps.x, + testProps.y + ); + }); + }); + }); + + describe('when buildMenu was called', () => { + describe('when buildMenu was called in dev environment', () => { + it('should call setupDevelopmentEnvironment', () => { + const setupDevelopmentEnvironmentBackup = + menuBuilder.setupDevelopmentEnvironment; + menuBuilder.setupDevelopmentEnvironment = jest.fn(); + + const backupNodeEnv = process.env.NODE_ENV; + const backupEnvDebugProd = process.env.DEBUG_PROD; + + process.env.NODE_ENV = 'development'; + + menuBuilder.buildMenu(); + + expect(menuBuilder.setupDevelopmentEnvironment).toBeCalled(); + + process.env.NODE_ENV = backupNodeEnv; + + // @ts-ignore + menuBuilder.setupDevelopmentEnvironment.mockClear(); + + process.env.DEBUG_PROD = 'true'; + + menuBuilder.buildMenu(); + expect(menuBuilder.setupDevelopmentEnvironment).toBeCalled(); + + process.env.NODE_ENV = backupNodeEnv; + process.env.DEBUG_PROD = backupEnvDebugProd; + menuBuilder.setupDevelopmentEnvironment = setupDevelopmentEnvironmentBackup; + }); + }); + + describe('when buildMenu was called when process.platform === "darwin"', () => { + it('should call buildDarwinTemplate and setApplicationMenu with built template', () => { + const buildDarwinTemplateBackup = menuBuilder.buildDarwinTemplate; + const testReturnMenu = [] as MenuItemConstructorOptions[]; + menuBuilder.buildDarwinTemplate = jest + .fn() + .mockReturnValueOnce(testReturnMenu); + const testMenuMock = { asdf: 'asdf' }; + // @ts-ignore + Menu.buildFromTemplate.mockReturnValueOnce(testMenuMock); + + const processBackup = process; + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = { + ...processBackup, + platform: 'darwin', + }; + + menuBuilder.buildMenu(); + + expect(Menu.buildFromTemplate).toBeCalledWith(testReturnMenu); + expect(menuBuilder.buildDarwinTemplate).toBeCalled(); + expect(Menu.setApplicationMenu).toBeCalledWith(testMenuMock); + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = processBackup; + menuBuilder.buildDarwinTemplate = buildDarwinTemplateBackup; + }); + }); + + describe('when buildMenu was called when process.platform !== "darwin"', () => { + it('should call setApplicationMenu with null', () => { + const processBackup = process; + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = { + ...processBackup, + platform: 'linux', + }; + + menuBuilder.buildMenu(); + + expect(Menu.setApplicationMenu).toBeCalledWith(null); + + // @ts-ignore + // eslint-disable-next-line no-global-assign + process = processBackup; + }); + }); + }); + + describe('when menu from buildDarwinTemplate was created', () => { + it('should match a snapshot', () => { + expect(menuBuilder.buildDarwinTemplate()).toMatchSnapshot(); + }); + + describe('when in About submenu menu quit label click event occured', () => { + it('should call app.quit() and stop() on signaling server, stop should be called before quit', () => { + const res = menuBuilder.buildDarwinTemplate(); + const submenuAbout = res[0]; + const quitLabel = + // @ts-ignore + submenuAbout.submenu[submenuAbout.submenu.length - 1]; + + quitLabel.click(); + + expect(signalingServer.stop).toHaveBeenCalled(); + expect(app.quit).toHaveBeenCalled(); + }); + }); + + describe('when menu was built in debug or dev environment', () => { + describe('when in View submenu, Reload menu label was clicked', () => { + it('should call .mainWindow.webContents.reload() on main window', () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevEnvDebugProd = process.env.DEBUG_PROD; + + process.env.NODE_ENV = 'development'; + + const menu1 = menuBuilder.buildDarwinTemplate(); + const submenuView1 = menu1[2]; + const reloadLabel = + // @ts-ignore + submenuView1.submenu[0]; + reloadLabel.click(); + + expect(testMainWindow.webContents.reload).toBeCalled(); + // @ts-ignore + testMainWindow.webContents.reload.mockClear(); + process.env.NODE_ENV = prevNodeEnv; + + process.env.DEBUG_PROD = 'true'; + const menu2 = menuBuilder.buildDarwinTemplate(); + const submenuView2 = menu2[2]; + const toggleDevTools = + // @ts-ignore + submenuView2.submenu[2]; + toggleDevTools.click(); + + expect(testMainWindow.webContents.toggleDevTools).toBeCalled(); + // @ts-ignore + testMainWindow.webContents.toggleDevTools.mockClear(); + + const menu3 = menuBuilder.buildDarwinTemplate(); + const submenuView3 = menu3[2]; + const toggleFullScreen = + // @ts-ignore + submenuView3.submenu[1]; + toggleFullScreen.click(); + + expect(testMainWindow.setFullScreen).toBeCalled(); + expect(testMainWindow.isFullScreen).toBeCalled(); + // @ts-ignore + testMainWindow.webContents.toggleDevTools.mockClear(); + + process.env.NODE_ENV = prevNodeEnv; + process.env.DEBUG_PROD = prevEnvDebugProd; + }); + }); + }); + + describe('when menu was built in production environment', () => { + describe('when toggle fullscreen was clicked', () => { + it('should call setFullsScreen and isFullScreen on main window', () => { + const menu = menuBuilder.buildDarwinTemplate(); + const submenuView = menu[2]; + const toggleFullScreen = + // @ts-ignore + submenuView.submenu[0]; + + toggleFullScreen.click(); + + expect(testMainWindow.setFullScreen).toBeCalled(); + expect(testMainWindow.isFullScreen).toBeCalled(); + }); + }); + }); + + describe('when Help submenu Lean More was clicked', () => { + it('shoud call shell open external with proper link to https://www.deskreen.com/', () => { + const menu = menuBuilder.buildDarwinTemplate(); + const submenuView = menu[4]; + const learnMore = + // @ts-ignore + submenuView.submenu[0]; + + learnMore.click(); + + expect(shell.openExternal).toBeCalledWith( + 'https://www.deskreen.com/' + ); + }); + }); + + describe('when Help submenu Documentation was clicked', () => { + it('shoud call shell open external with proper link to https://github.com/pavlobu/deskreen/blob/master/README.md', () => { + const menu = menuBuilder.buildDarwinTemplate(); + const submenuView = menu[4]; + const learnMore = + // @ts-ignore + submenuView.submenu[1]; + + learnMore.click(); + + expect(shell.openExternal).toBeCalledWith( + 'https://github.com/pavlobu/deskreen/blob/master/README.md' + ); + }); + }); + + describe('when Help submenu Community Discussions was clicked', () => { + it('shoud call shell open external with proper link to https://github.com/pavlobu/deskreen/issues', () => { + const menu = menuBuilder.buildDarwinTemplate(); + const submenuView = menu[4]; + const learnMore = + // @ts-ignore + submenuView.submenu[2]; + + learnMore.click(); + + expect(shell.openExternal).toBeCalledWith( + 'https://github.com/pavlobu/deskreen/issues' + ); + }); + }); + + describe('when Help submenu Search Issues was clicked', () => { + it('shoud call shell open external with proper link to https://github.com/pavlobu/deskreen/issues', () => { + const menu = menuBuilder.buildDarwinTemplate(); + const submenuView = menu[4]; + const learnMore = + // @ts-ignore + submenuView.submenu[3]; + + learnMore.click(); + + expect(shell.openExternal).toBeCalledWith( + 'https://github.com/pavlobu/deskreen/issues' + ); + }); + }); + }); + }); +}); diff --git a/app/menu.ts b/app/menu.ts index af8c17a..4f03a6d 100644 --- a/app/menu.ts +++ b/app/menu.ts @@ -7,7 +7,7 @@ import { MenuItemConstructorOptions, } from 'electron'; -import config from './configs/app.lang.config'; +// import config from './configs/app.lang.config'; import signalingServer from './server'; @@ -37,9 +37,6 @@ export default class MenuBuilder { if (process.platform === 'darwin') { const menu = Menu.buildFromTemplate(this.buildDarwinTemplate()); Menu.setApplicationMenu(menu); - } else if (process.env.NODE_ENV === 'development') { - const menu = Menu.buildFromTemplate(this.buildDefaultTemplate()); - Menu.setApplicationMenu(menu); } else { // for production, no menu for non MacOS app Menu.setApplicationMenu(null); @@ -146,14 +143,6 @@ export default class MenuBuilder { this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); }, }, - // TODO: remove this toggle dev menu in production!!!!!!! - { - label: 'Toggle Developer Tools', - accelerator: 'Alt+Command+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - }, ], }; const subMenuWindow: DarwinMenuItemConstructorOptions = { @@ -175,27 +164,27 @@ export default class MenuBuilder { { label: 'Learn More', click() { - shell.openExternal('https://electronjs.org'); + shell.openExternal('https://www.deskreen.com/'); }, }, { label: 'Documentation', click() { shell.openExternal( - 'https://github.com/electron/electron/tree/master/docs#readme' + 'https://github.com/pavlobu/deskreen/blob/master/README.md' ); }, }, { label: 'Community Discussions', click() { - shell.openExternal('https://www.electronjs.org/community'); + shell.openExternal('https://github.com/pavlobu/deskreen/issues'); }, }, { label: 'Search Issues', click() { - shell.openExternal('https://github.com/electron/electron/issues'); + shell.openExternal('https://github.com/pavlobu/deskreen/issues'); }, }, ], @@ -207,27 +196,27 @@ export default class MenuBuilder { ? subMenuViewDev : subMenuViewProd; - const languageSubmenu = config.languages.map((languageCode) => { - return { - label: this.i18n.t(languageCode), - type: 'radio', - checked: this.i18n.language === languageCode, - click: () => { - this.i18n.changeLanguage(languageCode); - setTimeout(() => { - // to fix for MacOS bug, not picking up new language on first click - if (this.i18n.language !== languageCode) { - this.i18n.changeLanguage(languageCode); - } - }, 500); - }, - }; - }); + // const languageSubmenu = config.languages.map((languageCode) => { + // return { + // label: this.i18n.t(languageCode), + // type: 'radio', + // checked: this.i18n.language === languageCode, + // click: () => { + // this.i18n.changeLanguage(languageCode); + // setTimeout(() => { + // // to fix for MacOS bug, not picking up new language on first click + // if (this.i18n.language !== languageCode) { + // this.i18n.changeLanguage(languageCode); + // } + // }, 500); + // }, + // }; + // }); - const languageMenu: MenuItemConstructorOptions = { - label: this.i18n.t('Language'), - submenu: languageSubmenu as MenuItemConstructorOptions[], - }; + // const languageMenu: MenuItemConstructorOptions = { + // label: this.i18n.t('Language'), + // submenu: languageSubmenu as MenuItemConstructorOptions[], + // }; return [ subMenuAbout, @@ -235,121 +224,7 @@ export default class MenuBuilder { subMenuView, subMenuWindow, subMenuHelp, - languageMenu, + // languageMenu, ]; } - - buildDefaultTemplate() { - const templateDefault = [ - { - label: '&File', - submenu: [ - { - label: '&Open', - accelerator: 'Ctrl+O', - }, - { - label: '&Close', - accelerator: 'Ctrl+W', - click: () => { - this.mainWindow.close(); - }, - }, - ], - }, - { - label: '&View', - submenu: - process.env.NODE_ENV === 'development' || - process.env.DEBUG_PROD === 'true' - ? [ - { - label: '&Reload', - accelerator: 'Ctrl+R', - click: () => { - this.mainWindow.webContents.reload(); - }, - }, - { - label: 'Toggle &Full Screen', - accelerator: 'F11', - click: () => { - this.mainWindow.setFullScreen( - !this.mainWindow.isFullScreen() - ); - }, - }, - { - label: 'Toggle &Developer Tools', - accelerator: 'Alt+Ctrl+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - }, - ] - : [ - { - label: 'Toggle &Full Screen', - accelerator: 'F11', - click: () => { - this.mainWindow.setFullScreen( - !this.mainWindow.isFullScreen() - ); - }, - }, - ], - }, - { - label: 'Help', - submenu: [ - { - label: 'Learn More', - click() { - shell.openExternal('https://electronjs.org'); - }, - }, - { - label: 'Documentation', - click() { - shell.openExternal( - 'https://github.com/electron/electron/tree/master/docs#readme' - ); - }, - }, - { - label: 'Community Discussions', - click() { - shell.openExternal('https://www.electronjs.org/community'); - }, - }, - { - label: 'Search Issues', - click() { - shell.openExternal('https://github.com/electron/electron/issues'); - }, - }, - ], - }, - ]; - - const languageSubmenu = config.languages.map((languageCode) => { - return { - label: this.i18n.t(languageCode), - type: 'radio', - checked: this.i18n.language === languageCode, - click: () => { - this.i18n.changeLanguage(languageCode); - }, - }; - }); - - const languageMenu = { - label: this.i18n.t('Language'), - submenu: languageSubmenu, - }; - - templateDefault.push(languageMenu); - - return templateDefault; - } } diff --git a/app/peerConnectionHelperRendererWindowIndex.spec.ts b/app/peerConnectionHelperRendererWindowIndex.spec.ts new file mode 100644 index 0000000..f76351b --- /dev/null +++ b/app/peerConnectionHelperRendererWindowIndex.spec.ts @@ -0,0 +1,291 @@ +import { ipcRenderer } from 'electron'; +import PeerConnection from './features/PeerConnection'; +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +import { handleIpcRenderer } from './peerConnectionHelperRendererWindowIndex'; + +jest.useFakeTimers(); + +jest.mock('electron', () => { + return { + ipcRenderer: { + on: jest.fn(), + send: jest.fn(), + }, + remote: { + getGlobal: jest.fn(), + }, + }; +}); +jest.mock('simple-peer'); +jest.mock('./features/PeerConnection'); + +describe('peerConnectionHelperRendererWindowIndex tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + // @ts-ignore + PeerConnection.mockClear(); + }); + + function mockAndGetPeerConnectionInstance() { + handleIpcRenderer(); + // @ts-ignore + let callback = ipcRenderer.on.mock.calls[0][1]; + // @ts-ignore + ipcRenderer.on.mockClear(); + callback(); + // @ts-ignore + // eslint-disable-next-line prefer-destructuring + callback = ipcRenderer.on.mock.calls[0][1]; + + callback(undefined, { + roomId: '12', + sharingSessionID: '39392', + user: 'asd', + appTheme: true, + appLanguage: 'bz', + }); + // @ts-ignore + return PeerConnection.mock.instances[0]; + } + + describe('when handleIpcRenderer was called', () => { + it('should set ipcRenderer.on("start-peer-connection" listener', () => { + handleIpcRenderer(); + + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'start-peer-connection', + expect.anything() + ); + }); + + describe('when ipcRenderer.on("start-peer-connection" callback occured', () => { + it('should set ipcRenderer.on("create-peer-connection-with-data"', () => { + handleIpcRenderer(); + + // @ts-ignore + const callback = ipcRenderer.on.mock.calls[0][1]; + // @ts-ignore + ipcRenderer.on.mockClear(); + + callback(); + + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'create-peer-connection-with-data', + expect.anything() + ); + }); + + it('should set ipcRenderer listeners', () => { + handleIpcRenderer(); + + // @ts-ignore + const callback = ipcRenderer.on.mock.calls[0][1]; + // @ts-ignore + ipcRenderer.on.mockClear(); + + callback(); + + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'create-peer-connection-with-data', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'create-peer-connection-with-data', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'set-desktop-capturer-source-id', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'call-peer', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'disconnect-by-host-machine-user', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'deny-connection-for-partner', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'send-user-allowed-to-connect', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'app-color-theme-changed', + expect.anything() + ); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'app-language-changed', + expect.anything() + ); + }); + + describe('when ipcRenderer.on("create-peer-connection-with-data" callback occured', () => { + it('should intialize PeerConnection', () => { + handleIpcRenderer(); + // @ts-ignore + let callback = ipcRenderer.on.mock.calls[0][1]; + // @ts-ignore + ipcRenderer.on.mockClear(); + callback(); + // @ts-ignore + // eslint-disable-next-line prefer-destructuring + callback = ipcRenderer.on.mock.calls[0][1]; + + callback(undefined, { + roomId: '12', + sharingSessionID: '39392', + user: 'asd', + appTheme: true, + appLanguage: 'bz', + }); + + expect(PeerConnection).toHaveBeenCalled(); + // @ts-ignore + const peerConnectionInstance = PeerConnection.mock.instances[0]; + expect( + peerConnectionInstance.setOnDeviceConnectedCallback + ).toBeCalled(); + }); + + describe('when on device connected callback occured', () => { + it('should call ipcRenderer.send("peer-connected" with device data', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + // eslint-disable-next-line prefer-destructuring + const callback = + // @ts-ignore + peerConnectionInstance.setOnDeviceConnectedCallback.mock + .calls[0][0]; + const testDeviceData = 'asd23faga'; + + callback(testDeviceData); + + expect(ipcRenderer.send).toHaveBeenCalledWith( + 'peer-connected', + testDeviceData + ); + }); + }); + }); + + describe('when ipcRenderer.on("set-desktop-capturer-source-id" callback occured', () => { + it('should call peerConnection.setDesktopCapturerSourceID(id) with proper source id', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + + const setDesktopCapturerSourceIdCallback = + // @ts-ignore + ipcRenderer.on.mock.calls[1][1]; + const testSourceID = '12411'; + + setDesktopCapturerSourceIdCallback(undefined, testSourceID); + + expect( + peerConnectionInstance.setDesktopCapturerSourceID + ).toHaveBeenCalledWith(testSourceID); + }); + }); + + describe('when ipcRenderer.on("call-peer" callback occured', () => { + it('should call peerConnection.callPeer()', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + + const callPeerCallback = + // @ts-ignore + ipcRenderer.on.mock.calls[2][1]; + + callPeerCallback(); + + expect(peerConnectionInstance.callPeer).toHaveBeenCalled(); + }); + }); + + describe('when ipcRenderer.on("disconnect-by-host-machine-user" callback occured', () => { + it('should call peerConnection.disconnectByHostMachineUser()', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + + const disconnectCallback = + // @ts-ignore + ipcRenderer.on.mock.calls[3][1]; + + disconnectCallback(); + + expect( + peerConnectionInstance.disconnectByHostMachineUser + ).toHaveBeenCalled(); + }); + }); + + describe('when ipcRenderer.on("deny-connection-for-partner" callback occured', () => { + it('should call peerConnection.denyConnectionForPartner()', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + + const denyConnectionCallback = + // @ts-ignore + ipcRenderer.on.mock.calls[4][1]; + + denyConnectionCallback(); + + expect( + peerConnectionInstance.denyConnectionForPartner + ).toHaveBeenCalled(); + }); + }); + + describe('when ipcRenderer.on("send-user-allowed-to-connect" callback occured', () => { + it('should call peerConnection.sendUserAllowedToConnect()', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + + const sendUserAllowedToConnectCallback = + // @ts-ignore + ipcRenderer.on.mock.calls[5][1]; + + sendUserAllowedToConnectCallback(); + + expect( + peerConnectionInstance.sendUserAllowedToConnect + ).toHaveBeenCalled(); + }); + }); + + describe('when ipcRenderer.on("app-color-theme-changed" callback occured', () => { + it('should call peerConnection.setAppTheme(newTheme)', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + + const setAppThemeCallback = + // @ts-ignore + ipcRenderer.on.mock.calls[6][1]; + const testTheme = true; + + setAppThemeCallback(undefined, testTheme); + + expect(peerConnectionInstance.setAppTheme).toHaveBeenCalledWith( + testTheme + ); + }); + }); + + describe('when ipcRenderer.on("app-language-changed" callback occured', () => { + it('should call peerConnection.testAppLang(newLang)', () => { + const peerConnectionInstance = mockAndGetPeerConnectionInstance(); + + const setAppLangCallback = + // @ts-ignore + ipcRenderer.on.mock.calls[7][1]; + const testAppLang = 'eu'; + + setAppLangCallback(undefined, testAppLang); + + expect(peerConnectionInstance.setAppLanguage).toHaveBeenCalledWith( + testAppLang + ); + }); + }); + }); + }); +}); diff --git a/app/peerConnectionHelperRendererWindowIndex.tsx b/app/peerConnectionHelperRendererWindowIndex.tsx index 41cca67..6953af2 100644 --- a/app/peerConnectionHelperRendererWindowIndex.tsx +++ b/app/peerConnectionHelperRendererWindowIndex.tsx @@ -1,62 +1,72 @@ import { ipcRenderer, remote } from 'electron'; import ConnectedDevicesService from './features/ConnectedDevicesService'; +import DesktopCapturerSourcesService from './features/DesktopCapturerSourcesService'; import PeerConnection from './features/PeerConnection'; -import SharingSessionService from './features/SharingSessionsService'; +import SharingSessionService from './features/SharingSessionService'; import RoomIDService from './server/RoomIDService'; -ipcRenderer.on('start-peer-connection', () => { - const roomIDService = remote.getGlobal('roomIDService') as RoomIDService; - const connectedDevicesService = remote.getGlobal( - 'connectedDevicesService' - ) as ConnectedDevicesService; - const sharingSessionsService = remote.getGlobal( - 'sharingSessionService' - ) as SharingSessionService; +// eslint-disable-next-line import/prefer-default-export +export function handleIpcRenderer() { + ipcRenderer.on('start-peer-connection', () => { + const desktopCapturerSourcesService = remote.getGlobal( + 'desktopCapturerSourcesService' + ) as DesktopCapturerSourcesService; + const roomIDService = remote.getGlobal('roomIDService') as RoomIDService; + const connectedDevicesService = remote.getGlobal( + 'connectedDevicesService' + ) as ConnectedDevicesService; + const sharingSessionService = remote.getGlobal( + 'sharingSessionService' + ) as SharingSessionService; - let peerConnection: PeerConnection; + let peerConnection: PeerConnection; - ipcRenderer.on('create-peer-connection-with-data', (_, data) => { - peerConnection = new PeerConnection( - data.roomID, - data.sharingSessionID, - data.user, - data.appTheme, // TODO getAppTheme - data.appLanguage, // TODO getLanguage - roomIDService, - connectedDevicesService, - sharingSessionsService - ); + ipcRenderer.on('create-peer-connection-with-data', (_, data) => { + peerConnection = new PeerConnection( + data.roomID, + data.sharingSessionID, + data.user, + data.appTheme, // TODO getAppTheme + data.appLanguage, // TODO getLanguage + roomIDService, + connectedDevicesService, + sharingSessionService, + desktopCapturerSourcesService + ); - peerConnection.setOnDeviceConnectedCallback((deviceData) => { - ipcRenderer.send('peer-connected', deviceData); + peerConnection.setOnDeviceConnectedCallback((deviceData) => { + ipcRenderer.send('peer-connected', deviceData); + }); + }); + + ipcRenderer.on('set-desktop-capturer-source-id', (_, id) => { + peerConnection.setDesktopCapturerSourceID(id); + }); + + ipcRenderer.on('call-peer', () => { + peerConnection.callPeer(); + }); + + ipcRenderer.on('disconnect-by-host-machine-user', () => { + peerConnection.disconnectByHostMachineUser(); + }); + + ipcRenderer.on('deny-connection-for-partner', () => { + peerConnection.denyConnectionForPartner(); + }); + + ipcRenderer.on('send-user-allowed-to-connect', () => { + peerConnection.sendUserAllowedToConnect(); + }); + + ipcRenderer.on('app-color-theme-changed', (_, newTheme: boolean) => { + peerConnection.setAppTheme(newTheme); + }); + + ipcRenderer.on('app-language-changed', (_, newLang: string) => { + peerConnection.setAppLanguage(newLang); }); }); +} - ipcRenderer.on('set-desktop-capturer-source-id', (_, id) => { - peerConnection.setDesktopCapturerSourceID(id); - }); - - ipcRenderer.on('call-peer', () => { - peerConnection.callPeer(); - }); - - ipcRenderer.on('disconnect-by-host-machine-user', () => { - peerConnection.disconnectByHostMachineUser(); - }); - - ipcRenderer.on('deny-connection-for-partner', () => { - peerConnection.denyConnectionForPartner(); - }); - - ipcRenderer.on('send-user-allowed-to-connect', () => { - peerConnection.sendUserAllowedToConnect(); - }); - - ipcRenderer.on('app-color-theme-changed', (_, newTheme: boolean) => { - peerConnection.setAppTheme(newTheme); - }); - - ipcRenderer.on('app-language-changed', (_, newLang: string) => { - peerConnection.setAppLanguage(newLang); - }); -}); +handleIpcRenderer(); diff --git a/app/rootReducer.ts b/app/rootReducer.ts index 35ea3e6..976ea43 100644 --- a/app/rootReducer.ts +++ b/app/rootReducer.ts @@ -2,11 +2,11 @@ import { combineReducers } from 'redux'; import { connectRouter } from 'connected-react-router'; import { History } from 'history'; // eslint-disable-next-line import/no-cycle -import counterReducer from './features/counter/counterSlice'; +// import counterReducer from './features/counter/counterSlice'; export default function createRootReducer(history: History) { return combineReducers({ router: connectRouter(history), - counter: counterReducer, + // counter: counterReducer, }); } diff --git a/app/server/Room.d.ts b/app/server/Room.d.ts new file mode 100644 index 0000000..7f2e69b --- /dev/null +++ b/app/server/Room.d.ts @@ -0,0 +1,6 @@ +interface Room { + id: string; + users: User[]; + isLocked: boolean; + createdAt: number; +} diff --git a/app/server/RoomIDService/index.ts b/app/server/RoomIDService/index.ts index f00c670..90fe38d 100644 --- a/app/server/RoomIDService/index.ts +++ b/app/server/RoomIDService/index.ts @@ -1,7 +1,8 @@ import shortID from 'shortid'; +import crypto from 'crypto'; export default class RoomIDService { - public takenRoomIDs: Set; + takenRoomIDs: Set; nextSimpleRoomID: number; @@ -11,12 +12,16 @@ export default class RoomIDService { // TODO: load saved taken room ids from local storage, will be useful for saved devices feature in FUTURE } - public getSimpleAvailableRoomID(): string { + getSimpleAvailableRoomID(): Promise { this.nextSimpleRoomID += 1; - return `${this.nextSimpleRoomID - 1}`; + return new Promise((resolve) => { + crypto.randomBytes(3, (_, buffer) => { + resolve(parseInt(buffer.toString('hex'), 16).toString().substr(0, 6)); + }); + }); } - public getShortIDStringOfAvailableRoom(): Promise { + getShortIDStringOfAvailableRoom(): Promise { return new Promise((resolve) => { let newID = shortID(); while (this.takenRoomIDs.has(newID)) { @@ -26,15 +31,15 @@ export default class RoomIDService { }); } - public markRoomIDAsTaken(id: string) { + markRoomIDAsTaken(id: string) { this.takenRoomIDs.add(id); } - public unmarkRoomIDAsTaken(id: string) { + unmarkRoomIDAsTaken(id: string) { this.takenRoomIDs.delete(id); } - public isRoomIDTaken(id: string) { + isRoomIDTaken(id: string) { return this.takenRoomIDs.has(id); } } diff --git a/app/server/darkwireSocket.spec.ts b/app/server/darkwireSocket.spec.ts index 9626f60..9bd8542 100644 --- a/app/server/darkwireSocket.spec.ts +++ b/app/server/darkwireSocket.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable class-methods-use-this */ /* eslint-disable no-new */ /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -5,6 +6,52 @@ import Io from 'socket.io'; import http from 'http'; import Koa from 'koa'; import DarkwireSocket from './darkwireSocket'; +import getStore from './store'; +import MemoryStore from './store/MemoryStore'; +import socketsIPService from './socketsIPService'; +import socketIOServerStore from './store/socketIOServerStore'; + +jest.useFakeTimers(); + +jest.mock('./store', () => { + let store: MemoryStore; + return { + __esModule: true, // this property makes it work + default: () => { + if (store) { + return store; + } + store = ({ + set: jest.fn(), + del: jest.fn(), + } as unknown) as MemoryStore; + return store; + }, + }; +}); +jest.mock('./socketsIPService', () => { + return { + __esModule: true, // this property makes it work + default: { + getSocketIPByID: jest.fn(), + getSocketIDByIP: jest.fn(), + }, + }; +}); +jest.mock('./store/socketIOServerStore.ts', () => { + const mockEmit = jest.fn(); + const mockedIO = { + sockets: { + connected: {}, + }, + to: jest.fn().mockImplementation(() => ({ + emit: mockEmit, + })), + }; + return { + getServer: () => mockedIO, + }; +}); const protocol = http; @@ -31,7 +78,16 @@ class MockConnectSocket { describe('DarkwireSocket tests', () => { const TEST_ROOM_ID = '123'; const TEST_ROOM_ID_HASH = '123321'; - const makeTestSocketOPTS = (socket: Io.Socket) => { + + let app: Koa; + let server: http.Server; + let io: Io.Server; + let socket: Io.Socket; + let socketToEmitMock: jest.Mock; + + const testRemoteAddress = '123.221.123.121'; + + const makeTestDarkwireSocketOPTS = () => { return { roomIdOriginal: TEST_ROOM_ID, roomId: TEST_ROOM_ID_HASH, @@ -45,12 +101,14 @@ describe('DarkwireSocket tests', () => { }; }; - let app: Koa; - let server: http.Server; - let io: Io.Server; - let socket: Io.Socket; - beforeEach(() => { + socketToEmitMock = jest.fn(); + // @ts-ignore + // DarkwireSocket.mockClear(); + + // // @ts-ignore + // DarkwireSocket.mockImplementation(jest.requireActual('./darkwireSocket')); + app = new Koa(); server = protocol.createServer(app.callback()); io = Io(server, { @@ -61,74 +119,804 @@ describe('DarkwireSocket tests', () => { io.on('connection', (receivedSocket) => { socket = receivedSocket; - }); - }); - - it('should set internal socket same as passed in constructor', () => { - io.emit('connection', { - join: () => {}, - }); - const customSocket = new DarkwireSocket(makeTestSocketOPTS(socket)); - - expect(customSocket.socket).toBe(socket); - }); - - it('should emit "ROOM_LOCKED" on internal socket object when .sendRoomLocked() is called', () => { - const mockEmitProperty = jest.fn(); - io.emit('connection', { - emit: mockEmitProperty, - join: () => {}, - }); - const customSocket = new DarkwireSocket(makeTestSocketOPTS(socket)); - - customSocket.sendRoomLocked(); - - expect(mockEmitProperty).toBeCalledWith('ROOM_LOCKED'); - }); - - it('should call .joinRoom() when socket is created and pass roomId as argument', () => { - const mockJoinProperty = jest.fn(); - io.emit('connection', { - join: mockJoinProperty, - }); - const testSocketOPTS = makeTestSocketOPTS(socket); - const { roomId } = testSocketOPTS; - - new DarkwireSocket(testSocketOPTS); - - expect(mockJoinProperty).toBeCalledWith(roomId, expect.anything()); - }); - - it('should call handleDisconnect when socket.on("disconnect") happened', async () => { - const mockDisconnectProperty = jest.fn(); - io.emit('connection', new MockConnectSocket()); - const darkwireSocket = new DarkwireSocket(makeTestSocketOPTS(socket)); - Object.defineProperty(darkwireSocket, 'handleDisconnect', { - value: mockDisconnectProperty, + socket.emit = jest.fn(); + socket.on = jest.fn(); + socket.join = jest.fn().mockImplementation((_, callback) => { + callback(); + }); + socket.to = jest.fn().mockImplementation(() => ({ + emit: socketToEmitMock, + })); + socket.disconnect = jest.fn(); + socket.request = { + connection: { + remoteAddress: testRemoteAddress, + }, + }; }); - await darkwireSocket.handleSocket(socket); - socket.emit('disconnect'); + if (socket) { + // @ts-ignore + socket.on.mockClear(); + // @ts-ignore + socket.emit.mockClear(); + // @ts-ignore + socket.join.mockClear(); + // @ts-ignore + socket.to.mockClear(); + // @ts-ignore + socket.disconnect.mockClear(); + } - expect(mockDisconnectProperty).toBeCalledTimes(1); + // @ts-ignore + getStore().set.mockClear(); + // @ts-ignore + getStore().del.mockClear(); + // @ts-ignore + socketsIPService.getSocketIDByIP.mockClear(); + // @ts-ignore + socketsIPService.getSocketIPByID.mockClear(); + + // @ts-ignore + socketIOServerStore.getServer().to().emit.mockClear(); + // @ts-ignore + socketIOServerStore.getServer().to.mockClear(); }); - it('should set TOGGLE_LOCK_ROOM, USER_DISCONNECT, USER_ENTER callbacks on socket when handleSocket is called', async () => { - io.emit('connection', new MockConnectSocket()); - const darkwireSocket = new DarkwireSocket(makeTestSocketOPTS(socket)); + describe('when DarkwireSocket is created with options properly', () => { + it('should set internal socket same as passed in constructor', () => { + io.emit('connection', { + join: () => {}, + }); + const customSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); - await darkwireSocket.handleSocket(socket); + expect(customSocket.socket).toBe(socket); + }); - expect( - ((socket as unknown) as MockConnectSocket).testObservers - ).toHaveProperty('TOGGLE_LOCK_ROOM'); + it('should call .joinRoom() and pass roomId as argument', () => { + const testSocketOPTS = makeTestDarkwireSocketOPTS(); + const { roomId } = testSocketOPTS; - expect( - ((socket as unknown) as MockConnectSocket).testObservers - ).toHaveProperty('USER_ENTER'); + new DarkwireSocket(testSocketOPTS); - expect( - ((socket as unknown) as MockConnectSocket).testObservers - ).toHaveProperty('USER_DISCONNECT'); + expect(socket.join).toBeCalledWith(roomId, expect.anything()); + }); + + describe('when room.isLocked', () => { + it('should call .sendRoomLocked() in constructor()', () => { + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + darkwireSocket.sendRoomLocked = jest.fn(); + const socketOptions = makeTestDarkwireSocketOPTS(); + socketOptions.room.isLocked = true; + + darkwireSocket.constructor(socketOptions); + + expect(darkwireSocket.sendRoomLocked).toBeCalled(); + }); + + it('should NOT call .init() in constructor()', () => { + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + darkwireSocket.sendRoomLocked = jest.fn(); + darkwireSocket.init = jest.fn(); + const socketOptions = makeTestDarkwireSocketOPTS(); + socketOptions.room.isLocked = true; + + darkwireSocket.constructor(socketOptions); + + expect(darkwireSocket.init).not.toBeCalled(); + }); + }); + + describe('when .sendRoomLocked() is called', () => { + it('should emit "ROOM_LOCKED" on internal socket object', () => { + const customSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + + customSocket.sendRoomLocked(); + + expect(socket.emit).toBeCalledWith('ROOM_LOCKED'); + }); + }); + + describe('when socket.on("disconnect") happened', () => { + it('should call handleDisconnect', async () => { + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + darkwireSocket.handleDisconnect = jest.fn(); + darkwireSocket.handleSocket(); + // @ts-ignore + const disconnectCallback = socket.on.mock.calls[7][1]; + + disconnectCallback(); + + expect(darkwireSocket.handleDisconnect).toBeCalled(); + }); + }); + + describe('when .init() is called', () => { + it('should call .handleSocket', async () => { + const testOpts = makeTestDarkwireSocketOPTS(); + const darkwireSocket = new DarkwireSocket(testOpts); + darkwireSocket.handleSocket = jest.fn(); + darkwireSocket.joinRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(undefined))); + + await darkwireSocket.init(); + + expect(darkwireSocket.handleSocket).toBeCalled(); + }); + }); + + describe('when .handleSocket() is called', () => { + it('should set socket.on event listeners', () => { + io.emit('connection', new MockConnectSocket()); + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + + darkwireSocket.handleSocket(); + + expect(socket.on).toHaveBeenCalledWith('GET_MY_IP', expect.anything()); + expect(socket.on).toHaveBeenCalledWith( + 'GET_IP_BY_SOCKET_ID', + expect.anything() + ); + expect(socket.on).toHaveBeenCalledWith( + 'IS_ROOM_LOCKED', + expect.anything() + ); + expect(socket.on).toHaveBeenCalledWith( + 'ENCRYPTED_MESSAGE', + expect.anything() + ); + expect(socket.on).toHaveBeenCalledWith( + 'DISCONNECT_SOCKET_BY_DEVICE_IP', + expect.anything() + ); + expect(socket.on).toHaveBeenCalledWith('USER_ENTER', expect.anything()); + expect(socket.on).toHaveBeenCalledWith( + 'TOGGLE_LOCK_ROOM', + expect.anything() + ); + expect(socket.on).toHaveBeenCalledWith('disconnect', expect.anything()); + expect(socket.on).toHaveBeenCalledWith( + 'USER_DISCONNECT', + expect.anything() + ); + }); + }); + + describe('when .saveRoom() is called', () => { + it('should store room to store', async () => { + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + const testRoom = { + id: '123', + users: [], + isLocked: false, + createdAt: 1234512, + }; + + await darkwireSocket.saveRoom(testRoom); + + expect(getStore().set).toHaveBeenCalledWith( + 'rooms', + darkwireSocket.roomId, + expect.anything() + ); + }); + }); + + describe('when .destroyRoom() is called', () => { + it('should delete room from store', async () => { + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + + await darkwireSocket.destroyRoom(); + + expect(getStore().del).toHaveBeenCalledWith( + 'rooms', + darkwireSocket.roomId + ); + }); + }); + + describe('when .fetchRoom() is called', () => { + it('whould return res from getStore().get or {}', async () => { + const testRoomJSON = { asdf: '234' }; + const testRoom = JSON.stringify(testRoomJSON); + const roomStore = getStore(); + roomStore.get = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(testRoom))); + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + + const res1 = await darkwireSocket.fetchRoom(); + + expect(roomStore.get).toHaveBeenCalled(); + expect(res1).toEqual(testRoomJSON); + + roomStore.get = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(undefined))); + const res2 = await darkwireSocket.fetchRoom(); + + expect(roomStore.get).toHaveBeenCalled(); + expect(res2).toEqual({}); + }); + }); + + describe('when .joinRoom() is called', () => { + it('should resolve', async () => { + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + + const res = await darkwireSocket.joinRoom(); + + expect(res).toBe(undefined); + }); + + describe('when error passed in join callback', () => { + it('should reject', async () => { + socket.join = jest.fn().mockImplementation((_, callback) => { + callback(new Error('ugly error')); + }); + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + + try { + // rejects with undefined here + await darkwireSocket.joinRoom(); + // eslint-disable-next-line jest/no-jasmine-globals + fail(); // should have rejected here + } catch (e) { + // eslint-disable-next-line jest/no-try-expect + expect(e).toBe(undefined); + } + }); + }); + }); + + describe('after .handleSocket() call all listeners are set', () => { + describe('when socket.on("GET_MY_IP" callback occured', () => { + it('should call acknowledgeFunction with proper ip', () => { + const testIP = '123.231.121.111'; + // @ts-ignore + socketsIPService.getSocketIPByID.mockImplementationOnce(() => testIP); + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleSocket(); + // @ts-ignore + const getMyIpCallback = darkwireSocket.socket.on.mock.calls[0][1]; + const acknowledgeFunctionMock = jest.fn(); + + getMyIpCallback(acknowledgeFunctionMock); + + expect(acknowledgeFunctionMock).toBeCalledWith(testIP); + }); + }); + + describe('when socket.on("GET_IP_BY_SOCKET_ID" callback occured', () => { + it('should call acknowledgeFunction with proper ip', () => { + const testIP = '123.231.121.111'; + // @ts-ignore + socketsIPService.getSocketIPByID.mockImplementationOnce(() => testIP); + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleSocket(); + const getMyIpBySocketIdCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[1][1]; + const acknowledgeFunctionMock = jest.fn(); + + getMyIpBySocketIdCallback(undefined, acknowledgeFunctionMock); + + expect(acknowledgeFunctionMock).toBeCalledWith(testIP); + }); + }); + + describe('when socket.on("IS_ROOM_LOCKED" callback occured', () => { + it('should call acknowledgeFunction with room.isLocked', async () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleSocket(); + const testIsRoomLocked = true; + const testRoom = { + id: 'string', + users: [], + isLocked: testIsRoomLocked, + createdAt: 1234132, + }; + const isRoomLockedCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[2][1]; + darkwireSocket.fetchRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(testRoom))); + const acknowledgeFunctionMock = jest.fn(); + + await isRoomLockedCallback(acknowledgeFunctionMock); + + expect(acknowledgeFunctionMock).toBeCalledWith(testIsRoomLocked); + }); + }); + + describe('when socket.on("ENCRYPTED_MESSAGE" callback occured', () => { + it('should call emit ENCRYPTED_MESSAGE to current roomId', () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleSocket(); + const testPayload = { + asd: '2gasd', + }; + const encryptedMessageCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[3][1]; + + encryptedMessageCallback(testPayload); + + expect(socket.to).toBeCalledWith(darkwireSocket.roomId); + expect(socketToEmitMock).toBeCalledWith( + 'ENCRYPTED_MESSAGE', + testPayload + ); + }); + }); + + describe('when socket.on("DISCONNECT_SOCKET_BY_DEVICE_IP" callback occured', () => { + describe('when called by room owner', () => { + describe('when socket id to disconnect is found', () => { + it('should call .handleDisconnect with proper socket', async () => { + const testSocketID = 'stringId123'; + const testRoom = { + users: [ + { + socketId: testSocketID, + isOwner: true, + }, + ], + isLocked: true, + createdAt: 1234132, + }; + // @ts-ignore + socketsIPService.getSocketIDByIP.mockImplementationOnce( + () => testSocketID + ); + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleDisconnect = jest.fn(); + darkwireSocket.socket.id = testSocketID; + darkwireSocket.handleSocket(); + darkwireSocket.fetchRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(testRoom))); + const testPayloadIPToSuccess = '132.213.123.123'; + const testPayload = { + ip: testPayloadIPToSuccess, + }; + const disconnectSocketByDeviceIpCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[4][1]; + const testSocket = ({ + socketToDisconnect: 'asdfasdf', + } as unknown) as Io.Socket; + socketIOServerStore.getServer().sockets.connected[ + testSocketID + ] = testSocket; + + await disconnectSocketByDeviceIpCallback(testPayload); + + expect(darkwireSocket.handleDisconnect).toBeCalledWith( + testSocket + ); + delete socketIOServerStore.getServer().sockets.connected[ + testSocketID + ]; + }); + }); + + describe('when socket id to disconnect is NOT found', () => { + it('should NOT call .handleDisconnect()', async () => { + const testSocketID = 'stringId123'; + const testRoom = { + users: [ + { + socketId: testSocketID, + isOwner: true, + }, + ], + isLocked: true, + createdAt: 1234132, + }; + // @ts-ignore + socketsIPService.getSocketIDByIP.mockImplementationOnce( + () => undefined // should return undefined here to simulate expected behavior for test + ); + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleDisconnect = jest.fn(); + darkwireSocket.socket.id = testSocketID; + darkwireSocket.handleSocket(); + darkwireSocket.fetchRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(testRoom))); + const testPayloadIPToSuccess = '132.213.123.123'; + const testPayload = { + ip: testPayloadIPToSuccess, + }; + const disconnectSocketByDeviceIpCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[4][1]; + + await disconnectSocketByDeviceIpCallback(testPayload); + + expect(darkwireSocket.handleDisconnect).not.toBeCalled(); + }); + }); + }); + + describe('when called by NOT a room owner', () => { + it('should NOT call socketsIPService.getSocketIDByIP(payload.ip) and .handleDisconnect()', async () => { + const testSocketID = 'stringId123'; + const testRoom = { + users: [ + { + socketId: testSocketID, + isOwner: false, // NOT owner!! this should be false always here to make test succeed + }, + ], + isLocked: true, + createdAt: 1234132, + }; + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleDisconnect = jest.fn(); + darkwireSocket.socket.id = testSocketID; + darkwireSocket.handleSocket(); + darkwireSocket.fetchRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(testRoom))); + const testPayloadIPToSuccess = '132.213.123.123'; + const testPayload = { + ip: testPayloadIPToSuccess, + }; + const disconnectSocketByDeviceIpCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[4][1]; + + await disconnectSocketByDeviceIpCallback(testPayload); + + expect(socketsIPService.getSocketIDByIP).not.toBeCalled(); + expect(darkwireSocket.handleDisconnect).not.toBeCalled(); + }); + }); + + describe('when .fetchRoom returned with NO users', () => { + it('should NOT call socketsIPService.getSocketIDByIP(payload.ip) and .handleDisconnect()', async () => { + const testSocketID = 'stringId123'; + const testRoom = { + users: undefined, // this should simulate condition for test + isLocked: true, + createdAt: 1234132, + }; + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleDisconnect = jest.fn(); + darkwireSocket.socket.id = testSocketID; + darkwireSocket.handleSocket(); + darkwireSocket.fetchRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(testRoom))); + const testPayloadIPToSuccess = '132.213.123.123'; + const testPayload = { + ip: testPayloadIPToSuccess, + }; + const disconnectSocketByDeviceIpCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[4][1]; + + await disconnectSocketByDeviceIpCallback(testPayload); + + expect(socketsIPService.getSocketIDByIP).not.toBeCalled(); + expect(darkwireSocket.handleDisconnect).not.toBeCalled(); + }); + }); + }); + + describe('when socket.on("USER_ENTER" callback occured', () => { + describe('when .fetchRoom returned empty room, like this -> {}', () => { + it('should call .saveRoom() and socketIOServerStore.getServer().to(roomId).emit("USER_ENTER"', async () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleSocket(); + darkwireSocket.fetchRoom = () => + new Promise((resolve) => resolve({})); + const userEnterCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[5][1]; + const testPayload = { publicKey: 'sdie2', ip: '123.123.123.123' }; + darkwireSocket.saveRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(undefined))); + + await userEnterCallback(testPayload); + + expect(darkwireSocket.saveRoom).toHaveBeenCalled(); + expect(socketIOServerStore.getServer().to).toBeCalledWith( + darkwireSocket.roomId + ); + expect( + socketIOServerStore.getServer().to('1234').emit + ).toHaveBeenCalledWith('USER_ENTER', expect.anything()); + }); + }); + + describe('when .fetchRoom NOT empty room', () => { + describe('if user already exists in room', () => { + it('should NOT call .saveRoom() and NOT call socketIOServerStore.getServer().to(roomId).emit("USER_ENTER"', async () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + const testUserPublicKey = 'sdie2'; + const testUser = { + publicKey: testUserPublicKey, + }; + const testRoom = { + id: darkwireSocket.roomId, + users: [testUser], + isLocked: false, + createdAt: Date.now(), + }; + darkwireSocket.handleSocket(); + darkwireSocket.fetchRoom = () => + new Promise((resolve) => resolve(testRoom)); + const userEnterCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[5][1]; + const testPayload = { + publicKey: testUserPublicKey, + ip: '123.123.123.123', + }; + darkwireSocket.saveRoom = jest + .fn() + .mockReturnValue(new Promise((resolve) => resolve(undefined))); + + await userEnterCallback(testPayload); + + expect(darkwireSocket.saveRoom).not.toHaveBeenCalled(); + expect(socketIOServerStore.getServer().to).not.toBeCalled(); + expect( + socketIOServerStore.getServer().to('1234').emit + ).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when socket.on("TOGGLE_LOCK_ROOM" callback occured', () => { + describe('when user is owner, who called toggle lock room', () => { + it('should call .saveRoom()', async () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + const testSocketID = '43132sd'; + const testUser = { + socketId: testSocketID, + isOwner: true, + }; + const isTestRoomLocked = false; + const testRoom = { + id: darkwireSocket.roomId, + users: [testUser], + isLocked: isTestRoomLocked, + createdAt: Date.now(), + }; + darkwireSocket.handleSocket(); + darkwireSocket.socket.id = testSocketID; + darkwireSocket.fetchRoom = () => + new Promise((resolve) => resolve(testRoom)); + darkwireSocket.saveRoom = jest + .fn() + .mockImplementation( + () => new Promise((resolve) => resolve(undefined)) + ); + const toggleLockRoomCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[6][1]; + + await toggleLockRoomCallback(); + + expect(darkwireSocket.saveRoom).toBeCalledWith({ + ...testRoom, + isLocked: !isTestRoomLocked, + }); + }); + }); + + describe('when user is not owner, who called toggle lock room', () => { + it('should not call .saveRoom', async () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + const testSocketID = '43132sd'; + const testUser = { + socketId: testSocketID, + isOwner: false, + }; + const isTestRoomLocked = false; + const testRoom = { + id: darkwireSocket.roomId, + users: [testUser], + isLocked: isTestRoomLocked, + createdAt: Date.now(), + }; + darkwireSocket.handleSocket(); + darkwireSocket.socket.id = testSocketID; + darkwireSocket.fetchRoom = () => + new Promise((resolve) => resolve(testRoom)); + darkwireSocket.saveRoom = jest + .fn() + .mockImplementation( + () => new Promise((resolve) => resolve(undefined)) + ); + const toggleLockRoomCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[6][1]; + + await toggleLockRoomCallback(); + + expect(darkwireSocket.saveRoom).not.toBeCalled(); + }); + }); + }); + + describe('when socket.on("USER_DISCONNECT" callback occured', () => { + it('should call .handleDisconnect with proper socket object', () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + darkwireSocket.handleSocket(); + darkwireSocket.handleDisconnect = jest.fn(); + const userDisconnectCallback = + // @ts-ignore + darkwireSocket.socket.on.mock.calls[8][1]; + + userDisconnectCallback(); + + expect(darkwireSocket.handleDisconnect).toBeCalledWith( + darkwireSocket.socket + ); + }); + }); + }); + + describe('when .handleDisconnect() was called', () => { + describe('when it was called by room owner (aka. localhost)', () => { + it('should call socket.disconnect(), disconnectAllUsers(), .destroyRoom(), socketIOServerStore.getServer().to(this.roomId).emit("USER_EXIT"', async () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + const testSocketID = '43132sd'; + const testUser = { + socketId: testSocketID, + isOwner: true, // AN OWNER!!! required for this test condition + }; + const testRoom = { + id: darkwireSocket.roomId, + users: [testUser], + isLocked: false, + createdAt: Date.now(), + }; + darkwireSocket.socket.id = testSocketID; + darkwireSocket.fetchRoom = () => + new Promise((resolve) => resolve(testRoom)); + darkwireSocket.disconnectAllUsers = jest.fn(); + darkwireSocket.destroyRoom = jest + .fn() + .mockImplementation( + () => new Promise((resolve) => resolve(undefined)) + ); + + await darkwireSocket.handleDisconnect(darkwireSocket.socket); + + expect(darkwireSocket.destroyRoom).toBeCalled(); + expect(darkwireSocket.disconnectAllUsers).toBeCalled(); + expect(socketIOServerStore.getServer().to).toBeCalledWith( + darkwireSocket.roomId + ); + expect(socketIOServerStore.getServer().to('').emit).toBeCalledWith( + 'USER_EXIT', + expect.anything() + ); + expect(darkwireSocket.socket.disconnect).toBeCalled(); + }); + }); + + describe('when it was called by NOT room owner (aka. client)', () => { + it('should call .saveRoom, socket.disconnect(), socketIOServerStore.getServer().to(this.roomId).emit("USER_EXIT", newRoom.users)', async () => { + const darkwireSocket = new DarkwireSocket( + makeTestDarkwireSocketOPTS() + ); + const testSocketID = '43132sd'; + const testUser = { + socketId: testSocketID, + isOwner: false, // NOT AN OWNER!!! required for this test condition + }; + const testRoom = { + id: darkwireSocket.roomId, + users: [testUser], + isLocked: false, + createdAt: Date.now(), + }; + darkwireSocket.socket.id = testSocketID; + darkwireSocket.fetchRoom = () => + new Promise((resolve) => resolve(testRoom)); + darkwireSocket.saveRoom = jest + .fn() + .mockImplementation( + () => new Promise((resolve) => resolve(undefined)) + ); + + await darkwireSocket.handleDisconnect(darkwireSocket.socket); + + expect(darkwireSocket.saveRoom).toBeCalled(); + expect(socketIOServerStore.getServer().to).toBeCalledWith( + darkwireSocket.roomId + ); + expect(socketIOServerStore.getServer().to('').emit).toBeCalledWith( + 'USER_EXIT', + expect.anything() + ); + expect(darkwireSocket.socket.disconnect).toBeCalled(); + }); + }); + }); + + describe('when .disconnectAllUsers() was called', () => { + it('should call disconnect all connected users to socket', () => { + const darkwireSocket = new DarkwireSocket(makeTestDarkwireSocketOPTS()); + const testSocketID1 = '43132sd'; + const testSocketID2 = '43132sd222'; + const testUser1 = { + socketId: testSocketID1, + isOwner: false, + }; + const testUser2 = { + socketId: testSocketID2, + isOwner: false, + }; + const testRoom = { + id: darkwireSocket.roomId, + users: [testUser1, testUser2], + isLocked: false, + createdAt: Date.now(), + }; + const getIOBackupConnected = socketIOServerStore.getServer().sockets + .connected; + socketIOServerStore.getServer().sockets.connected = { + // @ts-ignore + [testSocketID1]: { disconnect: jest.fn() }, + // @ts-ignore + [testSocketID2]: { disconnect: jest.fn() }, + }; + + darkwireSocket.disconnectAllUsers((testRoom as unknown) as Room); + + expect( + socketIOServerStore.getServer().sockets.connected[testSocketID1] + .disconnect + ).toBeCalled(); + expect( + socketIOServerStore.getServer().sockets.connected[testSocketID2] + .disconnect + ).toBeCalled(); + + socketIOServerStore.getServer().sockets.connected = getIOBackupConnected; + }); + }); }); }); diff --git a/app/server/darkwireSocket.ts b/app/server/darkwireSocket.ts index ab65a02..5136b47 100644 --- a/app/server/darkwireSocket.ts +++ b/app/server/darkwireSocket.ts @@ -1,32 +1,17 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* * original JS code from darkwire.io - * translated to typescript for Deskreen app + * translated and adapted to typescript for Deskreen app * */ /* eslint-disable no-async-promise-executor */ import _ from 'lodash'; import Io from 'socket.io'; -// eslint-disable-next-line import/no-cycle -import { getIO } from '.'; import socketsIPService from './socketsIPService'; import getStore from './store'; +import socketIOServerStore from './store/socketIOServerStore'; -const LOCALHOST_SOCKET_IP = '::1'; - -interface User { - socketId: string; - publicKey: string; - isOwner: boolean; - ip: string; -} - -interface Room { - id: string; - users: User[]; - isLocked: boolean; - createdAt: number; -} +const LOCALHOST_SOCKET_IP = '127.0.0.1'; interface SocketOPTS { roomId: string; @@ -56,13 +41,12 @@ export default class Socket implements SocketOPTS { return; } - this.init(opts); + this.init(); } - async init(opts: SocketOPTS) { - const { roomId, socket } = opts; - await this.joinRoom(roomId, socket); - this.handleSocket(socket); + async init() { + await this.joinRoom(); + this.handleSocket(); } sendRoomLocked() { @@ -74,7 +58,6 @@ export default class Socket implements SocketOPTS { ...room, updatedAt: Date.now(), }; - return getStore().set('rooms', this.roomId, JSON.stringify(json)); } @@ -90,48 +73,50 @@ export default class Socket implements SocketOPTS { } // eslint-disable-next-line class-methods-use-this - joinRoom(roomId: string, socket: Io.Socket) { + joinRoom() { return new Promise((resolve, reject) => { - socket.join(roomId, (err) => { + this.socket.join(this.roomId, (err) => { if (err) { reject(); } - resolve(); + resolve(undefined); }); }); } - async handleSocket(socket: Io.Socket) { - socket.on('GET_MY_IP', (acknowledgeFunction) => { - acknowledgeFunction(socketsIPService.getSocketIPByID(socket.id)); + handleSocket() { + this.socket.on('GET_MY_IP', (acknowledgeFunction) => { + acknowledgeFunction(socketsIPService.getSocketIPByID(this.socket.id)); }); - socket.on('GET_IP_BY_SOCKET_ID', (socketID, acknowledgeFunction) => { + this.socket.on('GET_IP_BY_SOCKET_ID', (socketID, acknowledgeFunction) => { acknowledgeFunction(socketsIPService.getSocketIPByID(socketID)); }); - socket.on('IS_ROOM_LOCKED', async (acknowledgeFunction) => { + this.socket.on('IS_ROOM_LOCKED', async (acknowledgeFunction) => { const room: Room = (await this.fetchRoom()) as Room; acknowledgeFunction(room.isLocked); }); - socket.on('ENCRYPTED_MESSAGE', (payload) => { - socket.to(this.roomId).emit('ENCRYPTED_MESSAGE', payload); + this.socket.on('ENCRYPTED_MESSAGE', (payload) => { + this.socket.to(this.roomId).emit('ENCRYPTED_MESSAGE', payload); }); - socket.on('DISCONNECT_SOCKET_BY_DEVICE_IP', async (payload) => { + this.socket.on('DISCONNECT_SOCKET_BY_DEVICE_IP', async (payload) => { const room: Room = (await this.fetchRoom()) as Room; const ownerUser = (room.users || []).find( - (u) => u.socketId === socket.id && u.isOwner + (u) => u.socketId === this.socket.id && u.isOwner ); if (!ownerUser) return; const socketIDToDisconnect = socketsIPService.getSocketIDByIP(payload.ip); if (!socketIDToDisconnect) return; - this.handleDisconnect(getIO().sockets.connected[socketIDToDisconnect]); + this.handleDisconnect( + socketIOServerStore.getServer().sockets.connected[socketIDToDisconnect] + ); }); - socket.on('USER_ENTER', async (payload) => { + this.socket.on('USER_ENTER', async (payload) => { let room: Room = (await this.fetchRoom()) as Room; if (_.isEmpty(room)) { room = { @@ -152,17 +137,19 @@ export default class Socket implements SocketOPTS { users: [ ...(room.users || []), { - socketId: socket.id, + socketId: this.socket.id, publicKey: payload.publicKey, - isOwner: - LOCALHOST_SOCKET_IP === socket.request.connection.remoteAddress, + isOwner: this.socket.request.connection.remoteAddress.includes( + LOCALHOST_SOCKET_IP + ), ip: payload.ip ? payload.ip : '', }, ], }; await this.saveRoom(newRoom); - getIO() + socketIOServerStore + .getServer() .to(this.roomId) .emit('USER_ENTER', { ...newRoom, @@ -170,17 +157,13 @@ export default class Socket implements SocketOPTS { }); }); - socket.on('TOGGLE_LOCK_ROOM', async (__, callback) => { + this.socket.on('TOGGLE_LOCK_ROOM', async () => { const room: Room = (await this.fetchRoom()) as Room; const user = (room.users || []).find( - (u) => u.socketId === socket.id && u.isOwner + (u) => u.socketId === this.socket.id && u.isOwner ); if (!user) { - // @ts-ignore - callback({ - isLocked: room.isLocked, - }); return; } @@ -188,29 +171,20 @@ export default class Socket implements SocketOPTS { ...room, isLocked: !room.isLocked, }); - - socket.to(this.roomId).emit('TOGGLE_LOCK_ROOM', { - locked: !room.isLocked, - publicKey: user && user.publicKey, - }); - - callback({ - isLocked: !room.isLocked, - }); }); - socket.on('disconnect', () => { - this.handleDisconnect(socket); + this.socket.on('disconnect', () => { + this.handleDisconnect(this.socket); }); - socket.on('USER_DISCONNECT', () => { - this.handleDisconnect(socket); + this.socket.on('USER_DISCONNECT', () => { + this.handleDisconnect(this.socket); }); } async handleDisconnect(socket: Io.Socket) { const room: Room = (await this.fetchRoom()) as Room; - const ownerUser = (room.users || []).find( + const isOwnerUser = !!(room.users || []).find( (u) => u.socketId === socket.id && u.isOwner ); @@ -224,24 +198,29 @@ export default class Socket implements SocketOPTS { })), }; - if (ownerUser) { - // if owner left diconnect all users - newRoom.users.forEach((u) => { - if (getIO().sockets.connected[u.socketId]) { - getIO().sockets.connected[u.socketId].disconnect(); - } - }); - newRoom.users = []; - } - - await this.saveRoom(newRoom); - - getIO().to(this.roomId).emit('USER_EXIT', newRoom.users); - - if (newRoom.users && newRoom.users.length === 0) { + if (isOwnerUser) { + this.disconnectAllUsers(newRoom); await this.destroyRoom(); + } else { + await this.saveRoom(newRoom); } + socketIOServerStore + .getServer() + .to(this.roomId) + .emit('USER_EXIT', newRoom.users); + socket.disconnect(true); } + + // eslint-disable-next-line class-methods-use-this + disconnectAllUsers(room: Room) { + room.users.forEach((u) => { + if (socketIOServerStore.getServer().sockets.connected[u.socketId]) { + socketIOServerStore + .getServer() + .sockets.connected[u.socketId].disconnect(); + } + }); + } } diff --git a/app/server/index.ts b/app/server/index.ts index d3bddd8..98adb6c 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -4,93 +4,35 @@ * by Pavlo (Paul) Buidenkov * */ -import http, { Server } from 'http'; +import http from 'http'; import express from 'express'; import Koa from 'koa'; +import crypto from 'crypto'; import Io from 'socket.io'; import cors from 'kcors'; import Router from 'koa-router'; -import crypto from 'crypto'; import koaStatic from 'koa-static'; import koaSend from 'koa-send'; -import getPort from 'get-port'; -// eslint-disable-next-line import/no-cycle -import DarkwireSocket from './darkwireSocket'; +import config from '../api/config'; +// import getPort from 'get-port'; import pollForInactiveRooms from './pollForInactiveRooms'; -import getStore from './store'; - import Logger from '../utils/LoggerWithFilePrefix'; import isProduction from '../utils/isProduction'; import SocketsIPService from './socketsIPService'; -import getDeskreenGlobal from '../mainProcessHelpers/getDeskreenGlobal'; +import socketIOServerStore from './store/socketIOServerStore'; +import getDeskreenGlobal from '../utils/mainProcessHelpers/getDeskreenGlobal'; +import DarkwireSocket from './darkwireSocket'; +import getStore from './store'; -const log = new Logger('app/server/index.ts'); - -const app = new Koa(); - -const router = new Router(); - -const store = getStore(); - -app.use(cors()); -app.use(router.routes()); - -function setStaticFileHeaders( - ctx: Koa.ParameterizedContext -) { - ctx.set({ - 'strict-transport-security': 'max-age=31536000', - 'X-Frame-Options': 'deny', - 'X-XSS-Protection': '1; mode=block', - 'X-Content-Type-Options': 'nosniff', - 'Referrer-Policy': 'no-referrer', - 'Feature-Policy': - "geolocation 'none'; vr 'none'; payment 'none'; microphone 'none'", - // 'Cache-Control': 'max-age=0', // make browser get fresh files and make new connection when client connected - }); -} - -const clientDistDirectory = isProduction() - ? `${__dirname}/client/build` - : `${__dirname}/../client/build`; - -if (clientDistDirectory) { - app.use(async (ctx, next) => { - setStaticFileHeaders(ctx); - await koaStatic(clientDistDirectory)(ctx, next); - }); - - app.use(async (ctx) => { - setStaticFileHeaders(ctx); - await koaSend(ctx, 'index.html', { root: clientDistDirectory }); - }); -} else { - app.use(async (ctx) => { - ctx.body = { ready: true }; - }); -} - -const protocol = http; - -const server = protocol.createServer(app.callback()); -const io = Io(server, { - pingInterval: 20000, - pingTimeout: 5000, - serveClient: false, -}); +const { port } = config; const getRoomIdHash = (id: string) => { return crypto.createHash('sha256').update(id).digest('hex'); }; -io.sockets.on('connection', (socket) => { - const socketId = socket.id; - const clientIp = socket.request.connection.remoteAddress; - SocketsIPService.setIPOfSocketID(socketId, clientIp); -}); - -io.on('connection', async (socket) => { +const ioHandleOnConnection = (socket: Io.Socket) => { const { roomId } = socket.handshake.query; + const store = getStore(); setTimeout(async () => { if (!getDeskreenGlobal().roomIDService.isRoomIDTaken(roomId)) { @@ -112,52 +54,108 @@ io.on('connection', async (socket) => { room, }); } - }, 1000); // timeout 1 second for throttling malitios connections -}); - -const init = async (PORT: number) => { - pollForInactiveRooms(); - - return server.listen(PORT, () => { - log.info(`Deskreen signaling server is online at port ${PORT}`); - }); + }, 500); // timeout 500 millisecond for throttling malitios connections }; -class SignalingServer { - private static instance: SignalingServer; +function setStaticFileHeaders( + ctx: Koa.ParameterizedContext +) { + ctx.set({ + 'strict-transport-security': 'max-age=31536000', + 'X-Frame-Options': 'deny', + 'X-XSS-Protection': '1; mode=block', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Feature-Policy': + "geolocation 'none'; vr 'none'; payment 'none'; microphone 'none'", + // 'Cache-Control': 'max-age=0', // make browser get fresh files and make new connection when client connected + }); +} - public expressApp: express.Application; +class DeskreenSignalingServer { + log = new Logger(__filename); - public server: Server; + expressApp: express.Application; - public port: number; + server = ({} as unknown) as http.Server; + + port: number; + + app: Koa | undefined; constructor() { this.expressApp = express(); - this.server = new Server(); - this.port = 3131; + this.port = parseInt((port as unknown) as string, 10); + this.init(); } - public async start(): Promise { - this.port = await getPort({ port: 3131 }); - this.server = await init(this.port); - log.info(`Deskreen signaling server started at port: ${this.port}`); + init() { + this.app = new Koa(); + const router = new Router(); + + this.app.use(cors()); + this.app.use(router.routes()); + + const clientDistDirectory = isProduction() + ? `${__dirname}/client/build` + : `${__dirname}/../client/build`; + + if (clientDistDirectory) { + this.app.use(async (ctx, next) => { + setStaticFileHeaders(ctx); + await koaStatic(clientDistDirectory)(ctx, next); + }); + + this.app.use(async (ctx) => { + setStaticFileHeaders(ctx); + await koaSend(ctx, 'index.html', { root: clientDistDirectory }); + }); + } else { + this.app.use(async (ctx) => { + ctx.body = { ready: true }; + }); + } + + const protocol = http; + + this.server = protocol.createServer(this.app.callback()); + const io = Io(this.server, { + pingInterval: 20000, + pingTimeout: 5000, + serveClient: false, + }); + + io.sockets.on('connection', (socket) => { + const socketId = socket.id; + const clientIp = socket.request.connection.remoteAddress; + SocketsIPService.setIPOfSocketID(socketId, clientIp); + }); + + io.on('connection', (socket) => { + ioHandleOnConnection(socket); + }); + + socketIOServerStore.setServer(io); + } + + async start() { + pollForInactiveRooms(); + this.port = parseInt((port as unknown) as string, 10); + this.server = this.callListenOnHttpServer(); return this.server; } - public stop(): void { - this.server.close(); + callListenOnHttpServer() { + return this.server.listen(this.port, () => { + this.log.info(`Deskreen signaling server is online at port ${this.port}`); + }); } - public static getInstance(): SignalingServer { - if (!SignalingServer.instance) { - SignalingServer.instance = new SignalingServer(); - } - - return SignalingServer.instance; + stop(): void { + this.server.close(); } } -export const getIO = () => io; +const deskreenServer = new DeskreenSignalingServer(); -export default SignalingServer.getInstance(); +export default deskreenServer; diff --git a/app/server/store/socketIOServerStore.ts b/app/server/store/socketIOServerStore.ts new file mode 100644 index 0000000..1c67aa4 --- /dev/null +++ b/app/server/store/socketIOServerStore.ts @@ -0,0 +1,17 @@ +import Io from 'socket.io'; + +class SocketIOServerStore { + ioServer = ({} as unknown) as Io.Server; + + setServer(server: Io.Server) { + this.ioServer = server; + } + + getServer() { + return this.ioServer; + } +} + +const store = new SocketIOServerStore(); + +export default store; diff --git a/app/utils/AppUpdater.ts b/app/utils/AppUpdater.ts new file mode 100644 index 0000000..6f3e9e7 --- /dev/null +++ b/app/utils/AppUpdater.ts @@ -0,0 +1,10 @@ +import { autoUpdater } from 'electron-updater'; +import log from 'electron-log'; + +export default class AppUpdater { + constructor() { + log.transports.file.level = 'info'; + autoUpdater.logger = log; + autoUpdater.checkForUpdatesAndNotify(); + } +} diff --git a/app/utils/crypto.ts b/app/utils/crypto.ts index ea03aa7..8df8546 100644 --- a/app/utils/crypto.ts +++ b/app/utils/crypto.ts @@ -12,11 +12,7 @@ export default class Crypto { createEncryptDecryptKeys() { return new Promise((resolve) => { - const keypair = forge.pki.rsa.generateKeyPair({ - bits: 2048, - e: 0x10001, - workers: -1, - }); + const keypair = forge.pki.rsa.generateKeyPair(1024); resolve(keypair); }); } diff --git a/app/utils/getNewVersionTag.ts b/app/utils/getNewVersionTag.ts new file mode 100644 index 0000000..1d5055e --- /dev/null +++ b/app/utils/getNewVersionTag.ts @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import axios from 'axios'; + +const githubApiRepoTagsUrl = + 'https://api.github.com/repos/pavlobu/circleCInodeapp/tags'; + +export default async function getNewVersionTag() { + let latestVersionTag = ''; + + const response = await axios({ + url: githubApiRepoTagsUrl, + method: 'get', + headers: { 'User-Agent': 'node.js' }, + }); + + const foundTag = response.data.find((tagData: any) => { + return (tagData.name as string).startsWith('v'); + }); + + latestVersionTag = foundTag ? foundTag.name : ''; + + return latestVersionTag; +} diff --git a/app/utils/installExtensions.ts b/app/utils/installExtensions.ts new file mode 100644 index 0000000..de3dae7 --- /dev/null +++ b/app/utils/installExtensions.ts @@ -0,0 +1,11 @@ +export default async function installExtensions() { + // eslint-disable-next-line global-require + const installer = require('electron-devtools-installer'); + const forceDownload = !!process.env.UPGRADE_EXTENSIONS; + const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS']; + + return Promise.all( + extensions.map((name) => installer.default(installer[name], forceDownload)) + // eslint-disable-next-line no-console + ).catch(console.log); +} diff --git a/app/utils/mainProcessHelpers/DeskreenGlobal.d.ts b/app/utils/mainProcessHelpers/DeskreenGlobal.d.ts new file mode 100644 index 0000000..e3dbae8 --- /dev/null +++ b/app/utils/mainProcessHelpers/DeskreenGlobal.d.ts @@ -0,0 +1,14 @@ +import ConnectedDevicesService from '../../features/ConnectedDevicesService'; +import SharingSessionService from '../../features/SharingSessionService'; +import RendererWebrtcHelpersService from '../../features/PeerConnectionHelperRendererService'; +import RoomIDService from '../../server/RoomIDService'; +import DesktopCapturerSources from '../../features/DesktopCapturerSourcesService'; + +interface DeskreenGlobal { + appPath: string; + rendererWebrtcHelpersService: RendererWebrtcHelpersService; + roomIDService: RoomIDService; + connectedDevicesService: ConnectedDevicesService; + sharingSessionService: SharingSessionService; + desktopCapturerSourcesService: DesktopCapturerSources; +} diff --git a/app/mainProcessHelpers/getDeskreenGlobal.ts b/app/utils/mainProcessHelpers/getDeskreenGlobal.ts similarity index 100% rename from app/mainProcessHelpers/getDeskreenGlobal.ts rename to app/utils/mainProcessHelpers/getDeskreenGlobal.ts diff --git a/app/mainProcessHelpers/initGlobals.ts b/app/utils/mainProcessHelpers/initGlobals.ts similarity index 64% rename from app/mainProcessHelpers/initGlobals.ts rename to app/utils/mainProcessHelpers/initGlobals.ts index 9d64c1d..1ca27bf 100644 --- a/app/mainProcessHelpers/initGlobals.ts +++ b/app/utils/mainProcessHelpers/initGlobals.ts @@ -1,8 +1,8 @@ -import ConnectedDevicesService from '../features/ConnectedDevicesService'; -import SharingSessionService from '../features/SharingSessionsService'; -import RendererWebrtcHelpersService from '../features/PeerConnectionHelperRendererService'; -import RoomIDService from '../server/RoomIDService'; -import DesktopCapturerSources from '../features/DesktopCapturerSourcesService'; +import ConnectedDevicesService from '../../features/ConnectedDevicesService'; +import SharingSessionService from '../../features/SharingSessionService'; +import RendererWebrtcHelpersService from '../../features/PeerConnectionHelperRendererService'; +import RoomIDService from '../../server/RoomIDService'; +import DesktopCapturerSources from '../../features/DesktopCapturerSourcesService'; import { DeskreenGlobal } from './DeskreenGlobal'; export default (appPath: string) => { diff --git a/package.json b/package.json index 097973b..24ea565 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "test-ux": "node -r @babel/register ./internals/scripts/CheckBuildsExist.js && cross-env NODE_ENV=test RUN_MODE=test testcafe --skip-js-errors electron:./app ./test/ux/Stepper.ux.ts", "test-ux-live": "node -r @babel/register ./internals/scripts/CheckBuildsExist.js && cross-env NODE_ENV=test RUN_MODE=test testcafe --live electron:./app ./test/ux/Stepper.ux.ts", "test-watch": "yarn jest --watch --silent", - "test-watch-no-silent": "yarn jest --watch", + "test-watch-not-silent": "yarn jest --watch", "sonar": "concurrently \"sonar-scanner\" \"cd app/client && sonar-scanner\" " }, "lint-staged": { diff --git a/resources/icon.icns b/resources/icon.icns index c2213ce..008ec56 100644 Binary files a/resources/icon.icns and b/resources/icon.icns differ diff --git a/resources/icon.ico b/resources/icon.ico index 98948ea..a821e93 100644 Binary files a/resources/icon.ico and b/resources/icon.ico differ diff --git a/resources/icon.png b/resources/icon.png old mode 100755 new mode 100644 index 755a6e5..a88f87e Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/resources/icons/1024x1024.png b/resources/icons/1024x1024.png deleted file mode 100755 index 5940b65..0000000 Binary files a/resources/icons/1024x1024.png and /dev/null differ diff --git a/resources/icons/128x128.png b/resources/icons/128x128.png deleted file mode 100755 index 14e578d..0000000 Binary files a/resources/icons/128x128.png and /dev/null differ diff --git a/resources/icons/16x16.png b/resources/icons/16x16.png deleted file mode 100755 index 260a46c..0000000 Binary files a/resources/icons/16x16.png and /dev/null differ diff --git a/resources/icons/24x24.png b/resources/icons/24x24.png deleted file mode 100755 index 5617241..0000000 Binary files a/resources/icons/24x24.png and /dev/null differ diff --git a/resources/icons/256x256.png b/resources/icons/256x256.png deleted file mode 100755 index 755a6e5..0000000 Binary files a/resources/icons/256x256.png and /dev/null differ diff --git a/resources/icons/32x32.png b/resources/icons/32x32.png deleted file mode 100755 index 63423df..0000000 Binary files a/resources/icons/32x32.png and /dev/null differ diff --git a/resources/icons/48x48.png b/resources/icons/48x48.png deleted file mode 100755 index 74d87a0..0000000 Binary files a/resources/icons/48x48.png and /dev/null differ diff --git a/resources/icons/512x512.png b/resources/icons/512x512.png deleted file mode 100755 index 313cd49..0000000 Binary files a/resources/icons/512x512.png and /dev/null differ diff --git a/resources/icons/64x64.png b/resources/icons/64x64.png deleted file mode 100755 index 6de0ec0..0000000 Binary files a/resources/icons/64x64.png and /dev/null differ diff --git a/resources/icons/96x96.png b/resources/icons/96x96.png deleted file mode 100755 index 8255ab5..0000000 Binary files a/resources/icons/96x96.png and /dev/null differ diff --git a/resources/icons/icon_1024x1024.png b/resources/icons/icon_1024x1024.png new file mode 100644 index 0000000..d09ff94 Binary files /dev/null and b/resources/icons/icon_1024x1024.png differ diff --git a/resources/icons/icon_128x128.png b/resources/icons/icon_128x128.png new file mode 100644 index 0000000..737c6d1 Binary files /dev/null and b/resources/icons/icon_128x128.png differ diff --git a/resources/icons/icon_16x16.png b/resources/icons/icon_16x16.png new file mode 100644 index 0000000..8777ed7 Binary files /dev/null and b/resources/icons/icon_16x16.png differ diff --git a/resources/icons/icon_24x24.png b/resources/icons/icon_24x24.png new file mode 100644 index 0000000..855ffa0 Binary files /dev/null and b/resources/icons/icon_24x24.png differ diff --git a/resources/icons/icon_256x256.png b/resources/icons/icon_256x256.png new file mode 100644 index 0000000..a88f87e Binary files /dev/null and b/resources/icons/icon_256x256.png differ diff --git a/resources/icons/icon_32x32.png b/resources/icons/icon_32x32.png new file mode 100644 index 0000000..08f8fc5 Binary files /dev/null and b/resources/icons/icon_32x32.png differ diff --git a/resources/icons/icon_48x48.png b/resources/icons/icon_48x48.png new file mode 100644 index 0000000..8ed0dc8 Binary files /dev/null and b/resources/icons/icon_48x48.png differ diff --git a/resources/icons/icon_512x512.png b/resources/icons/icon_512x512.png new file mode 100644 index 0000000..c218ea5 Binary files /dev/null and b/resources/icons/icon_512x512.png differ diff --git a/resources/icons/icon_64x64.png b/resources/icons/icon_64x64.png new file mode 100644 index 0000000..cae48e2 Binary files /dev/null and b/resources/icons/icon_64x64.png differ diff --git a/resources/icons/icon_96x96.png b/resources/icons/icon_96x96.png new file mode 100644 index 0000000..2e792bc Binary files /dev/null and b/resources/icons/icon_96x96.png differ diff --git a/sonar-project.properties b/sonar-project.properties index 82a4183..d7f3a54 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,11 +1,11 @@ -sonar.projectKey=test-electron-react-boilerplate +sonar.projectKey=deskreen-main # sonar.testExecutionReportPaths=test-reporter.xml sonar.typescript.lcov.reportPaths=coverage/lcov.info -sonar.cpd.exclusions=app/**/mocks/*,app/**/*.spec.ts,app/**/*.spec.tsx,app/**/*.test.ts,app/**/*.test.tsx,app/serviceWorker.ts,app/index.tsx -sonar.coverage.exclusions=app/**/mocks/*,app/**/*.spec.ts,app/**/*.spec.tsx,app/**/*.test.ts,app/**/*.test.tsx,app/serviceWorker.ts,app/index.tsx +sonar.cpd.exclusions=app/configs/*,app/**/__mocks__/*,app/**/mocks/*,app/**/*.spec.ts,app/**/*.spec.tsx,app/**/*.test.ts,app/**/*.test.tsx,app/serviceWorker.ts,app/index.tsx +sonar.coverage.exclusions=app/configs/*,app/**/__mocks__/*,app/**/mocks/*,app/**/*.spec.ts,app/**/*.spec.tsx,app/**/*.test.ts,app/**/*.test.tsx,app/serviceWorker.ts,app/index.tsx sonar.sources=app sonar.tests=test sonar.host.url=http://localhost:9000 -sonar.login=d0c254aaff5ebd89dd5c6f0663238ab6ad5fddea +sonar.login=7cdf1971934d910e71b44b78d54c154975c40199 sonar.exclusions=app/client/** # sonar.login=039884f95817f7b26d781d7cdd47430cb3734a0a diff --git a/test/e2e/HomePage.e2e.ts b/test/e2e/HomePage.e2e.ts deleted file mode 100644 index 61cbec1..0000000 --- a/test/e2e/HomePage.e2e.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint jest/expect-expect: off, jest/no-test-callback: off */ -import { ClientFunction, Selector } from 'testcafe'; - -const getPageUrl = ClientFunction(() => window.location.href); -const getPageTitle = ClientFunction(() => document.title); -const counterSelector = Selector('[data-tid="counter"]'); -const buttonsSelector = Selector('[data-tclass="btn"]'); -const clickToCounterLink = (t) => - t.click(Selector('button').withExactText('to Counter')); -const incrementButton = buttonsSelector.nth(0); -const decrementButton = buttonsSelector.nth(1); -const oddButton = buttonsSelector.nth(2); -const asyncButton = buttonsSelector.nth(3); -const getCounterText = () => counterSelector().innerText; -const assertNoConsoleErrors = async (t) => { - const { error } = await t.getBrowserConsoleMessages(); - await t.expect(error).eql([]); -}; - -fixture`Home Page`.page('../../app/app.html').afterEach(assertNoConsoleErrors); - -test('e2e', async (t) => { - await t.expect(getPageTitle()).eql('Deskreen'); -}); - -test('should open window and contain expected page title', async (t) => { - await t.expect(getPageTitle()).eql('Deskreen'); -}); - -test( - 'should not have any logs in console of main window', - assertNoConsoleErrors -); - -test('should navigate to Counter with click on the "to Counter" link', async (t) => { - await t.click('#to-counter').expect(getCounterText()).eql('0'); -}); - -test('should navigate to /counter', async (t) => { - await t.click('#to-counter').expect(getPageUrl()).contains('/counter'); -}); - -fixture`Counter Tests` - .page('../../app/app.html') - .beforeEach(clickToCounterLink) - .afterEach(assertNoConsoleErrors); - -test('should display updated count after the increment button click', async (t) => { - await t.click(incrementButton).expect(getCounterText()).eql('1'); -}); - -test('should display updated count after the descrement button click', async (t) => { - await t.click(decrementButton).expect(getCounterText()).eql('-1'); -}); - -test('should not change even counter if odd button clicked', async (t) => { - await t.click(oddButton).expect(getCounterText()).eql('0'); -}); - -test('should change odd counter if odd button clicked', async (t) => { - await t - .click(incrementButton) - .click(oddButton) - .expect(getCounterText()) - .eql('2'); -}); - -test('should change if async button clicked and a second later', async (t) => { - await t - .click(asyncButton) - .expect(getCounterText()) - .eql('0') - .expect(getCounterText()) - .eql('1'); -}); - -test('should back to home if back button clicked', async (t) => { - await t - .click('[data-tid="backButton"] > a') - .expect(Selector('[data-tid="container"]').visible) - .ok(); -});