From 65e5dbceb16fe785c4a4369f5ac6cec846902d92 Mon Sep 17 00:00:00 2001 From: Yousef Yassin Date: Sun, 7 Jan 2024 21:35:27 -0500 Subject: [PATCH] Resolve DOO-94 "Electron splash and window styles and transparent canvas" (#59) * Updated Websocket * Websocket mocks. * Add logger. * Fix live collab problems * Producer sharing. * Live collab with all edits except opacity * Consumer is working. * Disable sharing in a call. * customizability for rotate, resize, move * fix small linting problem * remove console logs * live collab with redo and undo * live collab with delete * live collab for text * debouncing opacity * live collab for text custimizability * Resized video * Working window alignment * Sharing works with pan and zoom * Documentation * Cleanup. * Workflow * Fix workflow * Add yarn cache dependency path. * Test emulator cache * Update tests and install firetools * Fix node tests * Electron IPC * Cleanup ipc listeners. * Screenshare from electron. * Draw transparent window, screen sources work, windowed kind of. Must be full screen. * Notifications bridged. * Comments. * More comments. * Fix linting. * Fix d.ts type definitions. * Move install firetools after cache. * Remove install fire-tools --------- Co-authored-by: Abdalla Abdelhadi --- .github/workflows/node.yml | 2 +- client/electron/electron-env.d.ts | 4 +- client/electron/ipc/ipcActions.ts | 10 + client/electron/ipc/ipcHandlers.ts | 86 ++++++++ client/electron/main.ts | 107 ++++++++-- client/electron/preload.ts | 142 ++++++++++--- client/electron/renderer.js | 30 +++ client/electron/window.ts | 107 ++++++++++ client/index.html | 4 +- client/package.json | 4 + client/pnpm-lock.yaml | 92 +++------ client/public/doodles-icon.png | Bin 0 -> 13496 bytes client/public/doodles-icon.svg | 4 + client/src/App.css | 3 +- client/src/App.tsx | 42 +++- client/src/Layout.tsx | 3 + client/src/components/lib/Canvas.tsx | 11 +- .../components/lib/CustomizabilityToolbar.tsx | 5 +- client/src/components/lib/DropDownMenu.tsx | 14 +- .../src/components/lib/ScreenSelectDialog.tsx | 89 ++++++++ client/src/components/lib/ShareScreen.tsx | 170 +++++++++------- .../src/components/lib/ShareScreenButton.tsx | 6 +- .../src/components/lib/Titlebar/Titlebar.tsx | 30 +++ .../lib/Titlebar/TitlebarLegacy.css | 191 ++++++++++++++++++ .../lib/Titlebar/TitlebarLegacy.tsx | 158 +++++++++++++++ .../lib/Titlebar/TitlebarWindows.css | 159 +++++++++++++++ .../lib/Titlebar/TitlebarWindows.tsx | 161 +++++++++++++++ client/src/components/lib/ToolBar.tsx | 7 +- .../src/components/lib/TransparencyButton.tsx | 66 ++++++ client/src/components/ui/alert-dialog.tsx | 139 +++++++++++++ client/src/constants.ts | 5 + client/src/data/ipc/ipcMessages.ts | 3 + client/src/global.d.ts | 35 +--- client/src/hooks/useIPCListener.tsx | 48 +++++ client/src/hooks/useShortcut.tsx | 7 + client/src/hooks/useWindowResize.tsx | 6 +- client/src/hooks/webrtc/useRTCConsumer.tsx | 4 +- client/src/hooks/webrtc/useRTCProducer.tsx | 73 +++++-- client/src/imageBlobReduce.d.ts | 31 +++ client/src/index.css | 2 +- client/src/lib/screenshare.ts | 32 ++- client/src/stores/AppStore.ts | 24 ++- client/src/stores/ElectronIPCStore.ts | 68 +++++++ client/src/types.ts | 14 ++ client/src/views/SignInPage.tsx | 2 +- client/src/views/SignUpPage.tsx | 2 +- client/src/views/Viewport.tsx | 21 +- node/src/lib/webrtc/RoomSFU.ts | 31 ++- node/src/lib/webrtc/SFUManager.ts | 14 +- 49 files changed, 1991 insertions(+), 277 deletions(-) create mode 100644 client/electron/ipc/ipcActions.ts create mode 100644 client/electron/ipc/ipcHandlers.ts create mode 100644 client/electron/renderer.js create mode 100644 client/electron/window.ts create mode 100644 client/public/doodles-icon.png create mode 100644 client/public/doodles-icon.svg create mode 100644 client/src/components/lib/ScreenSelectDialog.tsx create mode 100644 client/src/components/lib/Titlebar/Titlebar.tsx create mode 100644 client/src/components/lib/Titlebar/TitlebarLegacy.css create mode 100644 client/src/components/lib/Titlebar/TitlebarLegacy.tsx create mode 100644 client/src/components/lib/Titlebar/TitlebarWindows.css create mode 100644 client/src/components/lib/Titlebar/TitlebarWindows.tsx create mode 100644 client/src/components/lib/TransparencyButton.tsx create mode 100644 client/src/components/ui/alert-dialog.tsx create mode 100644 client/src/data/ipc/ipcMessages.ts create mode 100644 client/src/hooks/useIPCListener.tsx create mode 100644 client/src/imageBlobReduce.d.ts create mode 100644 client/src/stores/ElectronIPCStore.ts diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 0975c3a..29d291f 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -36,7 +36,6 @@ jobs: node-version: "18.x" cache: "yarn" cache-dependency-path: "./mod/fastfire/yarn.lock" - - run: npm install -g firebase-tools - uses: pnpm/action-setup@v2 with: @@ -50,6 +49,7 @@ jobs: key: ${{ runner.os }}-firebase-emulators-${{ hashFiles('./emulator-cache/**') }} continue-on-error: true + # - run: npm install -g firebase-tools - name: Get pnpm store directory shell: bash diff --git a/client/electron/electron-env.d.ts b/client/electron/electron-env.d.ts index 8b65abb..928eab8 100644 --- a/client/electron/electron-env.d.ts +++ b/client/electron/electron-env.d.ts @@ -15,8 +15,8 @@ declare namespace NodeJS { * │ * ``` */ - DIST: string + DIST: string; /** /dist/ or /public/ */ - PUBLIC: string + PUBLIC: string; } } diff --git a/client/electron/ipc/ipcActions.ts b/client/electron/ipc/ipcActions.ts new file mode 100644 index 0000000..5f2aa8a --- /dev/null +++ b/client/electron/ipc/ipcActions.ts @@ -0,0 +1,10 @@ +/** + * Defines the action handles that can be sent to the main process from the renderer process via IPC. + */ +export const IPC_ACTIONS = { + MAXIMIZE_WINDOW: 'maximize', + UNMAXIMIZE_WINDOW: 'unmaximize', + MINIMIZE_WINDOW: 'minimize', + CLOSE_WINDOW: 'close', + HANDLE_NOTIFICATION: 'notification', +}; diff --git a/client/electron/ipc/ipcHandlers.ts b/client/electron/ipc/ipcHandlers.ts new file mode 100644 index 0000000..ce477ec --- /dev/null +++ b/client/electron/ipc/ipcHandlers.ts @@ -0,0 +1,86 @@ +import { BrowserWindow, IpcMainEvent, ipcMain } from 'electron'; +import { IPC_ACTIONS } from './ipcActions'; +import { notification } from '../main'; + +const { + MAXIMIZE_WINDOW, + UNMAXIMIZE_WINDOW, + MINIMIZE_WINDOW, + CLOSE_WINDOW, + HANDLE_NOTIFICATION, +} = IPC_ACTIONS; + +/** + * Defines Electron IPC handlers and utility functions for window management. + * @author Yousef Yassin + */ + +/** + * Shared state object for storing global flags and variables. + * @property global_RecvMaximizedEventFlag Flag to track the reception of maximize events. + */ +export const shared = { global_RecvMaximizedEventFlag: false }; + +/** + * Retrieves the BrowserWindow associated with the given IPC event. + * @param event The IPC event. + * @returns The associated BrowserWindow or null if not found. + */ +const getWindow = (event: IpcMainEvent) => { + const webContents = event?.sender; + return BrowserWindow.fromWebContents(webContents); +}; + +/** + * Handles title bar events such as maximizing, unmaximizing, minimizing, and closing the window. + * @param event The IPC event. + * @param type The type of title bar event. + */ +const handleWindowTitlebarEvent = (event: IpcMainEvent, type: string) => { + if (type === MAXIMIZE_WINDOW) { + shared.global_RecvMaximizedEventFlag = true; + } + const window = getWindow(event); + // The type for title bar events is the same as the method name + window?.[type](); + if (type === CLOSE_WINDOW) { + window?.destroy(); + } +}; + +/** + * Handles notification events by updating notification properties and showing the notification. + * @param _event The IPC event. + * @param params Notification parameters. + * @param params.title The title of the notification. + * @param params.body The body/content of the notification. + */ +const handleNotification = ( + _event: IpcMainEvent, + { title, body }: { title: string; body: string }, +) => { + notification.title = title; + notification.body = body; + notification.show(); +}; + +/** + * Mapping of IPC events to their corresponding callback functions. + */ +const eventToCallback = { + [MAXIMIZE_WINDOW]: handleWindowTitlebarEvent, + [UNMAXIMIZE_WINDOW]: handleWindowTitlebarEvent, + [MINIMIZE_WINDOW]: handleWindowTitlebarEvent, + [CLOSE_WINDOW]: handleWindowTitlebarEvent, + [HANDLE_NOTIFICATION]: handleNotification, +}; + +/** + * Registers IPC event handlers (on the main process) + * based on the defined mappings. + */ +export const registerIPCHandlers = () => { + Object.entries(eventToCallback).forEach(([event, callback]) => + ipcMain.on(event, callback), + ); +}; diff --git a/client/electron/main.ts b/client/electron/main.ts index 735952d..e53e55d 100644 --- a/client/electron/main.ts +++ b/client/electron/main.ts @@ -1,6 +1,22 @@ -import { app, BrowserWindow } from 'electron'; +import { + app, + BrowserWindow, + desktopCapturer, + ipcMain, + Notification, + Tray, +} from 'electron'; import path from 'node:path'; +import { registerIPCHandlers } from './ipc/ipcHandlers'; +import { registerGlobalShortcuts, setupTray, setupWindow } from './window'; +/** + * @file Main Electron process handling application initialization and window creation. + * This is the entry point for the main process. + * @author Yousef Yassin + */ + +// Set environment variables for the build directory structure // The built directory structure // // ├─┬─┬ dist @@ -15,33 +31,94 @@ process.env.PUBLIC = app.isPackaged ? process.env.DIST : path.join(process.env.DIST, '../public'); -let win: BrowserWindow | null; // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']; +const APP_NAME = 'Doodles'; +const iconPath = path.join(process.env.PUBLIC ?? './', 'doodles-icon.png'); -function createWindow() { +// Global variables for notification, main window, and tray. They +// must be global to prevent garbage collection. +export let notification: Notification; +let win: BrowserWindow | null; +let tray: Tray | null; + +/** + * Creates the main application window. + */ +const createWindow = () => { win = new BrowserWindow({ - icon: path.join(process.env.PUBLIC ?? './', 'electron-vite.svg'), + icon: iconPath, webPreferences: { preload: path.join(__dirname, 'preload.js'), + nodeIntegration: true, + webSecurity: false, }, + transparent: true, + frame: false, + title: APP_NAME, }); + setupWindow(win, VITE_DEV_SERVER_URL ?? ''); + win.on('closed', () => (win = null)); + + // Setup the tray icon + tray = new Tray(iconPath); + setupTray(win, tray, APP_NAME); - // Test active push message to Renderer-process. - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', new Date().toLocaleString()); + // Setup notification with click handling + notification = new Notification({ icon: iconPath }); + notification.on('click', () => { + if (!win?.isVisible() || win?.isMinimized()) { + win?.show(); + } }); - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL); - } else { - // win.loadFile('dist/index.html') - win.loadFile(path.join(process.env.DIST ?? './', 'index.html')); - } -} + // Register global shortcuts for the main window + registerGlobalShortcuts(win); +}; +// Event handlers for app lifecycle and window focus/blur app.on('window-all-closed', () => { win = null; }); +app.on('browser-window-focus', () => { + win && win.webContents.send('focused'); +}); +app.on('browser-window-blur', () => { + win && win.webContents.send('blurred'); +}); +// Handle 'close-event' IPC event to hide the window instead of closing +ipcMain.handle('close-event', (e) => { + e.preventDefault(); + win && win.hide(); + e.returnValue = false; +}); -app.whenReady().then(createWindow); +// Application setup when app is ready +app.whenReady().then(() => { + // Register IPC handlers, create the main window. + registerIPCHandlers(); + createWindow(); + // Bridge the get-sources IPC event, fired from the renderer process, + // to the main process. This is necessary because desktopCapturer + // is only available in the main process. + ipcMain.handle('get-sources', () => { + try { + return desktopCapturer + .getSources({ types: ['window', 'screen'] }) + .then((sources) => + sources.map((source) => ({ + ...source, + thumbnail: { + // We need to invoke the thumbnail image methods + // in the main process since they can't be + // passed through the context bridge. + dataURL: source.thumbnail.toDataURL(), + aspect: source.thumbnail.getAspectRatio(), + }, + })), + ); + } catch (e) { + return []; + } + }); +}); diff --git a/client/electron/preload.ts b/client/electron/preload.ts index 4a771f8..3274b74 100644 --- a/client/electron/preload.ts +++ b/client/electron/preload.ts @@ -1,30 +1,57 @@ -function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) { - return new Promise(resolve => { +import { contextBridge, ipcRenderer } from 'electron'; +import { electronAPI } from '@electron-toolkit/preload'; +import { IPC_ACTIONS } from './ipc/ipcActions'; +import { readFileSync } from 'fs'; +import path from 'path'; + +/** + * Initializes Electron IPC handlers and utility functions for renderer process. + * @author Yousef Yassin / Template (https://github.com/maxstue/vite-reactts-electron-starter) + */ + +/** + * Promises that resolves when the DOM is in the specified ready state. + * @param condition Array of DocumentReadyState values to wait for. + * @returns Promise, Resolves to true when the ready state is met. + */ +const domReady = ( + condition: DocumentReadyState[] = ['complete', 'interactive'], +) => { + return new Promise((resolve) => { if (condition.includes(document.readyState)) { - resolve(true) + resolve(true); } else { document.addEventListener('readystatechange', () => { if (condition.includes(document.readyState)) { - resolve(true) + resolve(true); } - }) + }); } - }) -} + }); +}; +/** + * Provides safe DOM manipulation methods for appending and removing elements. + * @namespace + * @property append Safely appends a child element to a parent. + * @property remove Safely removes a child element from a parent. + */ const safeDOM = { append(parent: HTMLElement, child: HTMLElement) { - if (!Array.from(parent.children).find(e => e === child)) { - parent.appendChild(child) + if (!Array.from(parent.children).find((e) => e === child)) { + parent.appendChild(child); } }, remove(parent: HTMLElement, child: HTMLElement) { - if (Array.from(parent.children).find(e => e === child)) { - parent.removeChild(child) + if (Array.from(parent.children).find((e) => e === child)) { + parent.removeChild(child); } }, -} +}; +/** + * Loader Splash Screen (Temporary) + */ /** * https://tobiasahlin.com/spinkit * https://connoratherton.com/loaders @@ -32,7 +59,7 @@ const safeDOM = { * https://matejkustec.github.io/SpinThatShit */ function useLoading() { - const className = `loaders-css__square-spin` + const className = `loaders-css__square-spin`; const styleContent = ` @keyframes square-spin { 25% { transform: perspective(100px) rotateX(180deg) rotateY(0); } @@ -48,6 +75,7 @@ function useLoading() { animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite; } .app-loading-wrap { + -webkit-app-region: drag; position: fixed; top: 0; left: 0; @@ -59,34 +87,86 @@ function useLoading() { background: #282c34; z-index: 9; } - ` - const oStyle = document.createElement('style') - const oDiv = document.createElement('div') + `; + const oStyle = document.createElement('style'); + const oDiv = document.createElement('div'); - oStyle.id = 'app-loading-style' - oStyle.innerHTML = styleContent - oDiv.className = 'app-loading-wrap' - oDiv.innerHTML = `
` + oStyle.id = 'app-loading-style'; + oStyle.innerHTML = styleContent; + oDiv.className = 'app-loading-wrap'; + oDiv.innerHTML = `
`; return { + /** + * Appends loading spinner elements to the DOM. + */ appendLoading() { - safeDOM.append(document.head, oStyle) - safeDOM.append(document.body, oDiv) + safeDOM.append(document.head, oStyle); + safeDOM.append(document.body, oDiv); }, + /** + * Removes loading spinner elements from the DOM. + */ removeLoading() { - safeDOM.remove(document.head, oStyle) - safeDOM.remove(document.body, oDiv) + safeDOM.remove(document.head, oStyle); + safeDOM.remove(document.body, oDiv); }, - } + }; } // ---------------------------------------------------------------------- -const { appendLoading, removeLoading } = useLoading() -domReady().then(appendLoading) +const { appendLoading, removeLoading } = useLoading(); +domReady().then(appendLoading); -window.onmessage = ev => { - ev.data.payload === 'removeLoading' && removeLoading() -} +/** + * Listens for window messages to remove the loading spinner. + * @param ev The window message event. + */ +window.onmessage = (ev) => { + ev.data.payload === 'removeLoading' && removeLoading(); +}; +// Wait for 5 seconds, maximum. +setTimeout(removeLoading, 4999); -setTimeout(removeLoading, 4999) +// Injects renderer.js into the web page after DOM content is loaded. +// This is used to bridge the browser's media capture to Electron's desktopCapturer. +window.addEventListener('DOMContentLoaded', () => { + const rendererScript = document.createElement('script'); + rendererScript.text = readFileSync( + path.join(__dirname, '../electron/renderer.js'), + 'utf8', + ); + document.body.appendChild(rendererScript); +}); + +/** + * Expose Electron IPC API to the renderer process; this allows + * us to call these methods from the browser without importing + * Electron (which is blocked). We also expose the electron API types. + */ +contextBridge.exposeInMainWorld( + 'ipcAPI', + Object.values(IPC_ACTIONS).reduce( + (acc, value) => { + /** + * Sends an IPC message to the main process. + * @param args Arguments to be sent with the IPC message. + */ + acc[value] = (...args: unknown[]) => ipcRenderer.send(value, ...args); + return acc; + }, + { + /** + * Expose the electron API to receive messages from the main process. + * This is a workaround for dynamic import. + */ + electron: electronAPI, + /** + * Invokes the 'get-sources' IPC event to fetch media sources. + * @returns A promise that resolves with the fetched sources. + */ + getSources: () => ipcRenderer.invoke('get-sources'), + }, + ), +); diff --git a/client/electron/renderer.js b/client/electron/renderer.js new file mode 100644 index 0000000..65bb28e --- /dev/null +++ b/client/electron/renderer.js @@ -0,0 +1,30 @@ +/** + * @file Overriding mediaDevices APIs to integrate with Electron IPC for screen sharing. + * @author Yousef Yassin + */ + +// Override enumerateDevices to use IPC to fetch screen sources +navigator.mediaDevices.enumerateDevices = async () => + globalThis.ipcAPI.getSources(); + +/** + * Override getDisplayMedia to create a MediaStream with specified screen source. + * @param sourceId The ID of the screen source to capture. + * @returns A promise resolving to the MediaStream with the specified screen source. + */ +navigator.mediaDevices.getDisplayMedia = async (sourceId) => { + // Create MediaStream with screen source + return await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: sourceId, + minWidth: 1280, + maxWidth: 4096, + minHeight: 720, + maxHeight: 4096, + }, + }, + }); +}; diff --git a/client/electron/window.ts b/client/electron/window.ts new file mode 100644 index 0000000..cba3653 --- /dev/null +++ b/client/electron/window.ts @@ -0,0 +1,107 @@ +import { BrowserWindow, Menu, Tray, globalShortcut } from 'electron'; +import { shared } from './ipc/ipcHandlers'; +import path from 'node:path'; +import isDev from 'electron-is-dev'; + +/** + * @file Utility functions for setting up Electron window, global shortcuts, and tray. + * @author Yousef Yassin + */ + +/** + * Sets up the main Electron window with specified size and loads the renderer application URL. + * @param win The main Electron window to set. + * @param devServer The development server URL (empty string if not in development mode). + */ +export const setupWindow = (win: BrowserWindow, devServer: string) => { + win.setSize(1000, 800); + // Test active push message to Renderer-process. + win.webContents.on('did-finish-load', () => { + win?.webContents.send('main-process-message', new Date().toLocaleString()); + }); + + win.on('maximize', () => { + win && win.webContents.send('maximized'); + }); + win.on('unmaximize', () => { + win && win.webContents.send('unmaximized'); + }); + // Unmaximize the window when it is moved, but ignore + // the event that is sent when the window is maximized (because + // that also triggers a move). + win.on('move', () => { + if (!shared.global_RecvMaximizedEventFlag) { + win?.unmaximize(); + } else { + shared.global_RecvMaximizedEventFlag = false; + } + win && win.webContents.send('bounds-changed', win.getBounds()); + }); + win.on('resize', () => { + win && win.webContents.send('unmaximized'); + win && win.webContents.send('bounds-changed', win.getBounds()); + }); + + if (devServer) { + win.loadURL(devServer); + } else { + // win.loadFile('dist/index.html') + win.loadFile(path.join(process.env.DIST ?? './', 'index.html')); + } +}; + +// Flag to toggle click-through and always-on-top modes +let isClickThrough = false; +/** + * Registers global shortcuts for the main window, including DevTools and click-through mode toggling. + * @param win The main Electron window. + */ +export const registerGlobalShortcuts = (win: BrowserWindow) => { + globalShortcut.register('Alt+1', () => { + if (isDev) { + win && win.webContents.openDevTools({ mode: 'detach' }); + } + }); + globalShortcut.register('Ctrl+T', () => { + if (isDev) { + isClickThrough = !isClickThrough; + win?.setIgnoreMouseEvents(isClickThrough); + win?.setAlwaysOnTop(isClickThrough); + win?.webContents.send('click-through', isClickThrough); + } + }); +}; + +/** + * Sets up the Electron tray with a context menu and tooltip for the application. + * @param win The main Electron window. + * @param tray The Electron tray instance. + * @param appName The name of the application. + */ +export const setupTray = (win: BrowserWindow, tray: Tray, appName: string) => { + tray.setIgnoreDoubleClickEvents(true); + const trayMenu = Menu.buildFromTemplate([ + { + label: 'Show App', + click: () => { + win && win.show(); + }, + }, + { + label: 'Exit', + click: () => { + win && win.close(); + }, + }, + ]); + tray.on('click', () => { + if (win === null) return; + if (win.isVisible()) { + win.hide(); + } else { + win.show(); + } + }); + tray.setContextMenu(trayMenu); + tray.setToolTip(appName); +}; diff --git a/client/index.html b/client/index.html index e0d1c84..01c19c4 100644 --- a/client/index.html +++ b/client/index.html @@ -2,9 +2,9 @@ - + - Vite + React + TS + Doodles
diff --git a/client/package.json b/client/package.json index 63d51f4..0fb5452 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,9 @@ "preview": "vite preview" }, "dependencies": { + "@electron-toolkit/preload": "^3.0.0", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", @@ -27,6 +30,7 @@ "browser-fs-access": "^0.35.0", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "electron-is-dev": "^2.0.0", "file-saver": "^2.0.5", "firebase": "^10.4.0", "image-blob-reduce": "^4.1.0", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index e3e80f7..63619f1 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -5,6 +5,15 @@ settings: excludeLinksFromLockfile: false dependencies: + '@electron-toolkit/preload': + specifier: ^3.0.0 + version: 3.0.0(electron@24.8.2) + '@radix-ui/react-alert-dialog': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-aspect-ratio': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-avatar': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0) @@ -50,6 +59,9 @@ dependencies: clsx: specifier: ^2.0.0 version: 2.0.0 + electron-is-dev: + specifier: ^2.0.0 + version: 2.0.0 file-saver: specifier: ^2.0.5 version: 2.0.5 @@ -441,6 +453,14 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /@electron-toolkit/preload@3.0.0(electron@24.8.2): + resolution: {integrity: sha512-DTyvNGits43J1RiQGFN00/OxjjcTcozLWMjgQBANt97FFptEYiLVasq7kMC24nMwwoOpdDYY9CE6C+4wQJ55/w==} + peerDependencies: + electron: '>=13.0.0' + dependencies: + electron: 24.8.2 + dev: false + /@electron/get@2.0.2: resolution: {integrity: sha512-eFZVFoRXb3GFGd7Ak7W4+6jBl9wBtiZ4AaYOse97ej6mKj5tkyO0dUnUChs1IhJZtx1BENo4/p4WUTXpi6vT+g==} engines: {node: '>=12'} @@ -456,7 +476,6 @@ packages: global-agent: 3.0.0 transitivePeerDependencies: - supports-color - dev: true /@electron/universal@1.2.1: resolution: {integrity: sha512-7323HyMh7KBAl/nPDppdLsC87G6RwRU02dy5FPeGB1eS7rUePh55+WNWiDPLhFQqqVPHzh77M69uhmoT8XnwMQ==} @@ -2639,14 +2658,12 @@ packages: /@sindresorhus/is@4.6.0: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} - dev: true /@szmarczak/http-timer@4.0.6: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} dependencies: defer-to-connect: 2.0.1 - dev: true /@testing-library/dom@9.3.3: resolution: {integrity: sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==} @@ -2692,7 +2709,6 @@ packages: '@types/keyv': 3.1.4 '@types/node': 20.7.1 '@types/responselike': 1.0.0 - dev: true /@types/chai-subset@1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} @@ -2742,7 +2758,6 @@ packages: /@types/http-cache-semantics@4.0.1: resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==} - dev: true /@types/image-blob-reduce@4.1.4: resolution: {integrity: sha512-IMG+KVL7iy/g05zaJQFqc+Y0l+SlBVVW3GVKLM+ZkBtENoJSZ5nqIgtSZcs04QJvF+ticYJn9n3ZG427wg7Yhg==} @@ -2758,7 +2773,6 @@ packages: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: '@types/node': 20.7.1 - dev: true /@types/lodash@4.14.202: resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} @@ -2776,7 +2790,6 @@ packages: /@types/node@18.17.14: resolution: {integrity: sha512-ZE/5aB73CyGqgQULkLG87N9GnyGe5TcQjv34pwS8tfBs1IkCh0ASM69mydb2znqd6v0eX+9Ytvk6oQRqu8T1Vw==} - dev: true /@types/node@20.7.1: resolution: {integrity: sha512-LT+OIXpp2kj4E2S/p91BMe+VgGX2+lfO+XTpfXhh+bCk2LkQtHZSub8ewFBMGP5ClysPjTDFa4sMI8Q3n4T0wg==} @@ -2819,7 +2832,6 @@ packages: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: '@types/node': 20.7.1 - dev: true /@types/scheduler@0.16.3: resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} @@ -2849,7 +2861,6 @@ packages: requiresBuild: true dependencies: '@types/node': 20.7.1 - dev: true optional: true /@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.48.0)(typescript@5.2.2): @@ -3370,7 +3381,6 @@ packages: /boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} requiresBuild: true - dev: true optional: true /bplist-parser@0.2.0: @@ -3432,7 +3442,6 @@ packages: /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - dev: true /buffer-equal@1.0.0: resolution: {integrity: sha512-tcBWO2Dl4e7Asr9hTGcpVrCe+F7DubpmqWCTbj4FHLmjqO2hIaC383acQubWtRJhdceqs5uBHs6Es+Sk//RKiQ==} @@ -3505,7 +3514,6 @@ packages: /cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} - dev: true /cacheable-request@7.0.4: resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} @@ -3518,7 +3526,6 @@ packages: lowercase-keys: 2.0.0 normalize-url: 6.1.0 responselike: 2.0.1 - dev: true /call-bind@1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} @@ -3652,7 +3659,6 @@ packages: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} dependencies: mimic-response: 1.0.1 - dev: true /clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} @@ -3789,14 +3795,12 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} dependencies: mimic-response: 3.1.0 - dev: true /deep-eql@4.1.3: resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} @@ -3853,7 +3857,6 @@ packages: /defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} - dev: true /define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} @@ -3867,7 +3870,6 @@ packages: dependencies: has-property-descriptors: 1.0.0 object-keys: 1.1.1 - dev: true /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} @@ -3880,7 +3882,6 @@ packages: /detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} requiresBuild: true - dev: true optional: true /didyoumean@1.2.2: @@ -4006,6 +4007,10 @@ packages: - supports-color dev: true + /electron-is-dev@2.0.0: + resolution: {integrity: sha512-3X99K852Yoqu9AcW50qz3ibYBWY79/pBhlMCab8ToEWS48R0T9tyxRiQhwylE7zQdXrMnx2JKqUJyMPmt5FBqA==} + dev: false + /electron-osx-sign@0.6.0: resolution: {integrity: sha512-+hiIEb2Xxk6eDKJ2FFlpofCnemCbjbT5jz+BKGpVBrRNT3kWTGs4DfNX6IzGwgi33hUcXF+kFs9JW+r6Wc1LRg==} engines: {node: '>=4.0.0'} @@ -4051,7 +4056,6 @@ packages: extract-zip: 2.0.1 transitivePeerDependencies: - supports-color - dev: true /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4061,7 +4065,6 @@ packages: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: once: 1.4.0 - dev: true /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} @@ -4071,7 +4074,6 @@ packages: /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} - dev: true /es-abstract@1.22.1: resolution: {integrity: sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==} @@ -4178,7 +4180,6 @@ packages: /es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} requiresBuild: true - dev: true optional: true /esbuild@0.18.20: @@ -4223,7 +4224,6 @@ packages: /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - dev: true /eslint-config-prettier@9.0.0(eslint@8.48.0): resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} @@ -4457,7 +4457,6 @@ packages: '@types/yauzl': 2.10.0 transitivePeerDependencies: - supports-color - dev: true /extsprintf@1.4.1: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} @@ -4508,7 +4507,6 @@ packages: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} dependencies: pend: 1.2.0 - dev: true /fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} @@ -4636,7 +4634,6 @@ packages: graceful-fs: 4.2.11 jsonfile: 4.0.0 universalify: 0.1.2 - dev: true /fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} @@ -4704,7 +4701,6 @@ packages: has: 1.0.3 has-proto: 1.0.1 has-symbols: 1.0.3 - dev: true /get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} @@ -4716,7 +4712,6 @@ packages: engines: {node: '>=8'} dependencies: pump: 3.0.0 - dev: true /get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} @@ -4775,7 +4770,6 @@ packages: roarr: 2.15.4 semver: 7.5.4 serialize-error: 7.0.1 - dev: true optional: true /globals@11.12.0: @@ -4796,7 +4790,6 @@ packages: requiresBuild: true dependencies: define-properties: 1.2.0 - dev: true /globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} @@ -4835,11 +4828,9 @@ packages: lowercase-keys: 2.0.0 p-cancelable: 2.1.1 responselike: 2.0.1 - dev: true /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: true /graceful-readlink@1.0.1: resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} @@ -4883,19 +4874,16 @@ packages: requiresBuild: true dependencies: get-intrinsic: 1.2.1 - dev: true /has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} requiresBuild: true - dev: true /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} requiresBuild: true - dev: true /has-tostringtag@1.0.0: resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} @@ -4930,7 +4918,6 @@ packages: /http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} - dev: true /http-parser-js@0.5.8: resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==} @@ -4953,7 +4940,6 @@ packages: dependencies: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - dev: true /https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} @@ -5330,7 +5316,6 @@ packages: /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5343,7 +5328,6 @@ packages: /json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} requiresBuild: true - dev: true optional: true /json5@2.2.3: @@ -5360,7 +5344,6 @@ packages: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} optionalDependencies: graceful-fs: 4.2.11 - dev: true /jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -5398,7 +5381,6 @@ packages: resolution: {integrity: sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==} dependencies: json-buffer: 3.0.1 - dev: true /lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -5461,7 +5443,6 @@ packages: /lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} - dev: true /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5474,7 +5455,6 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 - dev: true /lucide-react@0.279.0(react@18.2.0): resolution: {integrity: sha512-LJ8g66+Bxc3t3x9vKTeK3wn3xucrOQGfJ9ou9GsBwCt2offsrT2BB90XrTrIzE1noYYDe2O8jZaRHi6sAHXNxw==} @@ -5502,7 +5482,6 @@ packages: requiresBuild: true dependencies: escape-string-regexp: 4.0.0 - dev: true optional: true /merge-stream@2.0.0: @@ -5549,12 +5528,10 @@ packages: /mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} - dev: true /mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - dev: true /minimatch@3.0.4: resolution: {integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==} @@ -5619,7 +5596,6 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true /multimath@2.0.0: resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} @@ -5688,7 +5664,6 @@ packages: /normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} - dev: true /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} @@ -5728,7 +5703,6 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} requiresBuild: true - dev: true /object.assign@4.1.4: resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} @@ -5818,7 +5792,6 @@ packages: /p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} - dev: true /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -5889,7 +5862,6 @@ packages: /pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - dev: true /perfect-freehand@1.2.0: resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} @@ -6055,7 +6027,6 @@ packages: /progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} - dev: true /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -6093,7 +6064,6 @@ packages: dependencies: end-of-stream: 1.4.4 once: 1.4.0 - dev: true /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} @@ -6106,7 +6076,6 @@ packages: /quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - dev: true /raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} @@ -6258,7 +6227,6 @@ packages: /resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - dev: true /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} @@ -6286,7 +6254,6 @@ packages: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} dependencies: lowercase-keys: 2.0.0 - dev: true /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} @@ -6317,7 +6284,6 @@ packages: json-stringify-safe: 5.0.1 semver-compare: 1.0.0 sprintf-js: 1.1.2 - dev: true optional: true /rollup@2.79.1: @@ -6401,13 +6367,11 @@ packages: /semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} requiresBuild: true - dev: true optional: true /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - dev: true /semver@7.0.0: resolution: {integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==} @@ -6420,7 +6384,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true /serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} @@ -6428,7 +6391,6 @@ packages: requiresBuild: true dependencies: type-fest: 0.13.1 - dev: true optional: true /shebang-command@2.0.0: @@ -6508,7 +6470,6 @@ packages: /sprintf-js@1.1.2: resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==} requiresBuild: true - dev: true optional: true /stackback@0.0.2: @@ -6631,7 +6592,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} @@ -6839,7 +6799,6 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} requiresBuild: true - dev: true optional: true /type-fest@0.20.2: @@ -6907,7 +6866,6 @@ packages: /universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} - dev: true /universalify@2.0.0: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} @@ -7284,7 +7242,6 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true /yaml@2.3.2: resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==} @@ -7311,7 +7268,6 @@ packages: dependencies: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - dev: true /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} diff --git a/client/public/doodles-icon.png b/client/public/doodles-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4b149ffa14d050be6f51ec02ab624fabd937ee15 GIT binary patch literal 13496 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 literal 0 HcmV?d00001 diff --git a/client/public/doodles-icon.svg b/client/public/doodles-icon.svg new file mode 100644 index 0000000..496b1e4 --- /dev/null +++ b/client/public/doodles-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/App.css b/client/src/App.css index 9c611dc..7455d47 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,2 +1,3 @@ * { margin: 0; padding: 0;} -body, html { height:100%; } \ No newline at end of file +root { height: 100%;} +body, html { height:100%; background-color: transparent !important; } diff --git a/client/src/App.tsx b/client/src/App.tsx index a008d33..b98ecfd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,9 +1,47 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import './App.css'; import Bootstrap from './Bootstrap'; +import Titlebar from './components/lib/Titlebar/Titlebar'; +import { IS_ELECTRON_INSTANCE } from './constants'; +import { useAppStore } from './stores/AppStore'; +import { useElectronIPCStore } from './stores/ElectronIPCStore'; function App() { - return ; + const { isTransparent } = useAppStore(['isTransparent']); + const { isWindowActive } = useElectronIPCStore(['isWindowActive']); + + // Set root height to 100% when transparent + useEffect(() => { + const root = document.getElementById('root'); + const radixThemes = document.getElementsByClassName( + 'radix-themes', + )[0] as HTMLElement; + if (IS_ELECTRON_INSTANCE && isTransparent) { + root && (root.style.height = '100%'); + radixThemes && (radixThemes.style.height = '100%'); + } else { + root && (root.style.height = ''); + radixThemes && (radixThemes.style.height = '100%'); + } + }, [isTransparent]); + + return ( +
+ {IS_ELECTRON_INSTANCE && } + +
+ ); } export default App; diff --git a/client/src/Layout.tsx b/client/src/Layout.tsx index 6b293cd..6f7b3f5 100644 --- a/client/src/Layout.tsx +++ b/client/src/Layout.tsx @@ -9,6 +9,8 @@ import useWindowResize from './hooks/useWindowResize'; import { useSocket } from './hooks/useSocket'; import { useShortcuts } from './hooks/useShortcut'; import Viewport from './views/Viewport'; +import useIPCListener from './hooks/useIPCListener'; +import { IS_ELECTRON_INSTANCE } from './constants'; /** * Layout component that handles routing between pages, and @@ -22,6 +24,7 @@ const Layout = () => { useWindowResize(); useShortcuts(); useSocket(); + IS_ELECTRON_INSTANCE && useIPCListener(); const { mode } = useAppStore(['mode']); switch (mode) { diff --git a/client/src/components/lib/Canvas.tsx b/client/src/components/lib/Canvas.tsx index c6e19a8..7d45f30 100644 --- a/client/src/components/lib/Canvas.tsx +++ b/client/src/components/lib/Canvas.tsx @@ -19,7 +19,7 @@ import { } from '@/types'; import { useWebSocketStore } from '@/stores/WebSocketStore'; import { getScaleOffset } from '@/lib/canvasElements/render'; -import { PERIPHERAL_CODES } from '@/constants'; +import { IS_ELECTRON_INSTANCE, PERIPHERAL_CODES } from '@/constants'; import { getCanvasContext, setCursor } from '@/lib/misc'; import { imageCache } from '../../lib/cache'; import { generateRandId } from '@/lib/bytes'; @@ -61,6 +61,7 @@ export default function Canvas() { 'panOffset', 'setPanOffset', 'setAction', + 'isTransparent', ]); const { addCanvasShape, @@ -169,9 +170,11 @@ export default function Canvas() { * @param e The mouse event containing the raw mouse coordinates. * @returns The normalized mouse coordinates. */ + const titlebarHeight = IS_ELECTRON_INSTANCE ? 30 : 0; const getMouseCoordinates = (e: MouseEvent) => { const clientX = (e.clientX - panOffset.x * zoom + scaleOffset.x) / zoom; - const clientY = (e.clientY - panOffset.y * zoom + scaleOffset.y) / zoom; + const clientY = + (e.clientY - titlebarHeight - panOffset.y * zoom + scaleOffset.y) / zoom; return { clientX, clientY }; }; @@ -609,7 +612,9 @@ export default function Canvas() { <> { {/* Background Color */} {colorSet.has(types[selectedElementIds[0]]) && - fillStyles[selectedElementIds[0]] != 'none' && ( + (fillStyles[selectedElementIds[0]] !== 'none' || + types[selectedElementIds[0]] === 'text') && ( <>

Color

{' '} diff --git a/client/src/components/lib/DropDownMenu.tsx b/client/src/components/lib/DropDownMenu.tsx index b310b1b..7ceb66c 100644 --- a/client/src/components/lib/DropDownMenu.tsx +++ b/client/src/components/lib/DropDownMenu.tsx @@ -12,6 +12,7 @@ import { } from '@radix-ui/react-icons'; import { useAppStore } from '@/stores/AppStore'; import CanvasColorToolGroup, { canvasColourTypes } from './CanvasBackground'; +import { IS_ELECTRON_INSTANCE } from '@/constants'; /** * Creates a DropDownMenu for Canvas @@ -23,7 +24,10 @@ const DropDownMenu = ({ }: { viewportRef: React.RefObject; }) => { - const { isFullscreen } = useAppStore(['isFullscreen']); + const { isFullscreen, isTransparent } = useAppStore([ + 'isFullscreen', + 'isTransparent', + ]); //Handle button functionailities const handleInfo = () => { @@ -41,7 +45,13 @@ const DropDownMenu = ({ diff --git a/client/src/components/lib/ScreenSelectDialog.tsx b/client/src/components/lib/ScreenSelectDialog.tsx new file mode 100644 index 0000000..3277926 --- /dev/null +++ b/client/src/components/lib/ScreenSelectDialog.tsx @@ -0,0 +1,89 @@ +import { StreamSource } from '@/types'; +import React, { useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import * as AspectRatio from '@radix-ui/react-aspect-ratio'; + +/** + * An alert dialog that is controlled by the `open` prop. It displays a list of + * screen sources and allows the user to select one. When the user clicks + * continue, the `onContinue` callback is called with the selected source ID. + * @author Yousef Yassin + */ + +const ScreenSelectDialog = ({ + open, + setOpen, + streamSources, + onContinue, +}: { + open: boolean; + setOpen: (value: boolean) => void; + streamSources: StreamSource[]; + onContinue: (sourceId: string) => void; +}) => { + const [selectedSourceId, setSelectedSourceId] = useState(null); + return ( + + + + Select a screen to share! + + Let the annotation begin! + +
+ {streamSources + // Only screen sources are supported in transparent mode. + .map((source) => ( +
+
setSelectedSourceId(source.id)} + > + + + +
+

+ {source.name} +

+
+ ))} +
+
+ + setSelectedSourceId(null)} + > + Cancel + + selectedSourceId && onContinue(selectedSourceId)} + > + Continue + + +
+
+ ); +}; + +export default ScreenSelectDialog; diff --git a/client/src/components/lib/ShareScreen.tsx b/client/src/components/lib/ShareScreen.tsx index 4303070..9ea0dd6 100644 --- a/client/src/components/lib/ShareScreen.tsx +++ b/client/src/components/lib/ShareScreen.tsx @@ -5,26 +5,25 @@ import { getScaleOffset } from '@/lib/canvasElements/render'; import { useAppStore } from '@/stores/AppStore'; import { useWebSocketStore } from '@/stores/WebSocketStore'; import React, { useEffect, useMemo, useRef, useState } from 'react'; +import ScreenSelectDialog from './ScreenSelectDialog'; +import { StreamSource } from '@/types'; /** * Defines a background video element that renders the shared screen (from the priducer, or incoming stream for a consumer). * @author Yousef Yassin */ -// TODO: Send these, since consumers can't read them. -const getStreamDetails = (stream: MediaStream | null) => - ({ - aspectRatio: 1920 / 1080, - height: 1080, - width: 1920, - ...stream?.getVideoTracks()[0].getSettings(), - }) as { aspectRatio: number; height: number; width: number }; - const ShareScreen = () => { /** Reference for the video element */ const videoRef = useRef(null); + const [trueVideoDimensions, setTrueVideoDimensions] = useState<{ + height: number; + width: number; + }>({ height: 0, width: 0 }); /** State for the screen sharing stream */ const [screenStream, setScreenStream] = useState(null); + const [screenSelectDialogOpen, setScreenSelectDialogOpen] = useState(false); + const [streamSources, setStreamSources] = useState([]); /** Reference to the WS client */ const { socket } = useWebSocketStore(['socket']); const { @@ -36,6 +35,7 @@ const ShareScreen = () => { zoom, panOffset, canvasColor, + isTransparent, } = useAppStore([ 'setIsInCall', 'appWidth', @@ -45,15 +45,17 @@ const ShareScreen = () => { 'zoom', 'panOffset', 'canvasColor', + 'isTransparent', ]); /** RTCPeer references, for cleanup; the hooks handle the connections. */ - const producerPeerRef = useRTCProducer(screenStream, setScreenStream); + const { peerRef: producerPeerRef, setSelectedSourceId } = useRTCProducer( + screenStream, + setScreenStream, + setScreenSelectDialogOpen, + setStreamSources, + ); const consumerPeerRef = useRTCConsumer(setScreenStream); - const { width: trueVideoWidth, height: trueVideoHeight } = useMemo( - () => getStreamDetails(screenStream), - [screenStream], - ); const scaleOffset = useMemo( () => getScaleOffset(appHeight, appWidth, zoom), [appHeight, appWidth, zoom], @@ -87,73 +89,101 @@ const ShareScreen = () => { setIsInCall(screenStream !== null); if (videoRef.current && screenStream !== null) { videoRef.current.srcObject = screenStream; - const { height: videoHeight, width: videoWidth } = - getStreamDetails(screenStream); - const [tx, ty] = [ - (appWidth - videoWidth) / 2, - (appHeight - videoHeight) / 2, - ]; + videoRef.current.onresize = (e) => { + const { videoHeight: height, videoWidth: width } = (e.target ?? {}) as { + videoWidth: number | undefined; + videoHeight: number | undefined; + }; + if (width && height) { + setTrueVideoDimensions({ width, height }); + } + }; + videoRef.current.onloadedmetadata = (e) => { + const { videoHeight: height, videoWidth: width } = (e.target ?? {}) as { + videoWidth: number | undefined; + videoHeight: number | undefined; + }; + if (width && height) { + setTrueVideoDimensions({ width, height }); + } + }; + } + }, [screenStream, videoRef.current]); + + useEffect(() => { + // We only want to set the offset when the video is loaded; we also don't + // load our own video in transparency mode so we don't want this to fire (it should + // be disabled in the store anyway). + if (videoRef.current !== null) { + const { width, height } = trueVideoDimensions; + const [tx, ty] = [(appWidth - width) / 2, (appHeight - height) / 2]; setPanOffset(tx, ty); setAppZoom(1); } - }, [screenStream, videoRef.current]); + }, [trueVideoDimensions]); // The video element with a wrapper to clip overflow. We use CSS transformation // to scale and translate the video according to our zoom and pan offsets. return ( -
- {screenStream !== null && ( -
-
+ )} +
+ + ); }; diff --git a/client/src/components/lib/ShareScreenButton.tsx b/client/src/components/lib/ShareScreenButton.tsx index 5f2b05a..6f65636 100644 --- a/client/src/components/lib/ShareScreenButton.tsx +++ b/client/src/components/lib/ShareScreenButton.tsx @@ -25,7 +25,6 @@ const ShareScreenIcon = ({ className }: { className: string }) => { fillRule="evenodd" clipRule="evenodd" d="M10.0046 6.6503C7.24714 6.6503 5.00464 8.863 5.00464 11.5839V38.7184C5.00464 41.4417 7.24714 43.6519 10.0046 43.6519H27.5046V48.5855H17.5046V53.519H42.5046V48.5855H32.5046V43.6519H50.0046C52.7621 43.6519 55.0046 41.4417 55.0046 38.7184V11.5839C55.0046 8.863 52.7621 6.6503 50.0046 6.6503H10.0046ZM15.0046 37.485C19.1746 31.7189 24.6646 29.098 33.0046 29.098V35.8508L45.0046 24.3186L33.0046 12.8172V19.385C21.3346 21.0501 16.6846 29.2521 15.0046 37.485Z" - fillOpacity="0.6" /> ); @@ -45,10 +44,7 @@ const UnshareScreenIcon = ({ className }: { className: string }) => { fill="none" xmlns="http://www.w3.org/2000/svg" > - + ); }; diff --git a/client/src/components/lib/Titlebar/Titlebar.tsx b/client/src/components/lib/Titlebar/Titlebar.tsx new file mode 100644 index 0000000..8f14ad5 --- /dev/null +++ b/client/src/components/lib/Titlebar/Titlebar.tsx @@ -0,0 +1,30 @@ +import React, { useEffect, useState } from 'react'; +import TitlebarWin from './TitlebarWindows'; +import TitlebarLeg from './TitlebarLegacy'; + +const osNameMap = { + Win: 'Windows', + Mac: 'MacOS', + X11: 'UNIX', + Linux: 'Linux', +} as const; + +const Titlebar = ({ title, fg }: { title: string; fg: string }) => { + const [os, setOs] = useState('Windows'); + + useEffect(() => { + Object.values(osNameMap).forEach((osName) => { + if (navigator.appVersion.indexOf(osName) !== -1) { + setOs(osName); + } + }); + }, []); + + return os !== 'MacOS' ? ( + + ) : ( + + ); +}; + +export default Titlebar; diff --git a/client/src/components/lib/Titlebar/TitlebarLegacy.css b/client/src/components/lib/Titlebar/TitlebarLegacy.css new file mode 100644 index 0000000..1b7801c --- /dev/null +++ b/client/src/components/lib/Titlebar/TitlebarLegacy.css @@ -0,0 +1,191 @@ +@import url('https://fonts.googleapis.com/css2?family=Pathway+Gothic+One&display=swap'); + + +.Titlebar { + /* -webkit-app-region: drag; */ +} + +.Titlebar-drag-region { + top: 0; + left: 0; + display: block; + position: absolute; + width: 100%; + height: 100%; + z-index: -1; + -webkit-app-region: drag; +} + +.resizer { + -webkit-app-region: no-drag; + position: absolute; + top: 0; + width: 100%; + height: 20%; +} + +.Title-Bar { + top: 0px; + width: 100%; + height: 30px; + background-color:transparent; + display: flex; + font-family: 'Open Sans', sans-serif; + position: relative; +} + +.Title-Bar-inactive { + top: 0px; + width: 100%; + height: 30px; + /* background-color: #80ff00; */ + background-color: transparent; + display: flex; + font-family: 'Open Sans', sans-serif; + position: relative; +} + +.Title-Bar__section-icon { + display: flex; + flex-direction: row; + align-items: center; + width: 120px; + position: relative; + /* background-color: rgb(248, 14, 178); */ + font-family: 'Pathway Gothic One', sans-serif; +} + +.section-icon__logo { + padding-left: 5px; + padding-top: 4px; + color: rgba(255, 255, 255, 0.3); +} + +.section-icon__title { + filter: brightness(0.5); + font-size: 17px; + padding-top: 6px; + padding-left: 24px; +} + +.Title-Bar__section-menubar { + display: flex; + flex-direction: row; + align-items: center; + width: 165px; + /* background-color: rgb(49, 14, 248); */ +} + +.section-menubar_button { + padding-left: 10px; + padding-right: 10px; + /* background-color: purple; */ +} + +.Title-Bar__section-center { + /* background-color: rgb(157, 255, 0); */ + display: flex; + flex-direction: row; + flex: 1; + align-items: center; +} + +.Title-Bar__section-windows-control { + /* background-color: green; */ + display: flex; + flex-direction: row; + align-items: center; + width: 90px; + /* needs no-drag for color changing div*/ + -webkit-app-region: no-drag; +} + +.section-windows-control_box { + width: 30px; + height: 30px; + /* background-color: purple; */ + display: flex; + align-items: center; + justify-content: center; + transition: all 0.05s ease-in; +} + +.minimize-active_logo { + fill: #525252; + transition: all 0.05s ease-in; +} + +.minimize-active_logo:hover { + fill: #F8B92B; + transition: all 0.05s ease-in; +} + +.minimize-inactive_logo { + fill: #3f3f3f; + transition: all 0.05s ease-in; +} + +.minimize-inactive_logo:hover { + fill: #F8B92B; + transition: all 0.05s ease-in; +} + +.maximize-active_logo { + fill: #525252; + transition: all 0.05s ease-in; +} + +.maximize-active_logo:hover { + fill: #2BC73B; + transition: all 0.05s ease-in; +} + +.maximize-inactive_logo { + fill: #3f3f3f; + transition: all 0.05s ease-in; +} + +.maximize-inactive_logo:hover { + fill: #2BC73B; + transition: all 0.05s ease-in; +} + +.unmaximize-active_logo { + fill: #525252; + transition: all 0.05s ease-in; +} + +.unmaximize-active_logo:hover { + fill: #2BC73B; + transition: all 0.05s ease-in; +} + +.unmaximize-inactive_logo { + fill: #3f3f3f; + transition: all 0.05s ease-in; +} + +.unmaximize-inactive_logo:hover { + fill: #2BC73B; + transition: all 0.05s ease-in; +} + +.close-active_logo { + fill: #525252; + transition: all 0.05s ease-in; +} + +.close-active_logo:hover { + fill: #F35D58; + transition: all 0.05s ease-in; +} + +.close-inactive_logo { + fill: #3f3f3f; + transition: all 0.05s ease-in; +} + +.close-inactive_logo:hover { + fill: #F35D58; + transition: all 0.05s ease-in; +} \ No newline at end of file diff --git a/client/src/components/lib/Titlebar/TitlebarLegacy.tsx b/client/src/components/lib/Titlebar/TitlebarLegacy.tsx new file mode 100644 index 0000000..6257001 --- /dev/null +++ b/client/src/components/lib/Titlebar/TitlebarLegacy.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from 'react'; +import { ipcAPI, ipcRenderer } from '@/data/ipc/ipcMessages'; +import './TitlebarLegacy.css'; + +/** + * Defines the titlebar for the MacOS platform. + * @author Yousef Yassin + */ + +const TitlebarLeg = ({ title }: { title: string }) => { + const [isActive, setIsActive] = useState(true); + const [isMaximized, setIsMaximized] = useState(true); + + useEffect(() => { + ipcRenderer.on('focused', () => { + setIsActive(true); + }); + + ipcRenderer.on('blurred', () => { + setIsActive(false); + }); + + ipcRenderer.on('maximized', () => { + setIsMaximized(true); + }); + + ipcRenderer.on('unmaximized', () => { + setIsMaximized(false); + }); + ipcRenderer.removeAllListeners('focused'); + ipcRenderer.removeAllListeners('blurred'); + ipcRenderer.removeAllListeners('maximized'); + ipcRenderer.removeAllListeners('unmaximized'); + }, []); + + const minimizeHandler = () => { + ipcAPI.minimize('minimize'); + }; + + const maximizeHandler = () => { + ipcAPI.maximize('maximize'); + }; + + const unmaximizeHandler = () => { + ipcAPI.unmaximize('unmaximize'); + }; + + const closeHandler = () => { + ipcAPI.close('close'); + }; + + return ( +
+
+
+
+
{title}
+
+
+
+
+
+ + + +
+ {isMaximized ? ( +
+ + + +
+ ) : ( +
+ + + +
+ )} +
+ + + +
+
+
+
+
+ ); +}; + +export default TitlebarLeg; diff --git a/client/src/components/lib/Titlebar/TitlebarWindows.css b/client/src/components/lib/Titlebar/TitlebarWindows.css new file mode 100644 index 0000000..c3f80f5 --- /dev/null +++ b/client/src/components/lib/Titlebar/TitlebarWindows.css @@ -0,0 +1,159 @@ +@import url('https://fonts.googleapis.com/css2?family=Pathway+Gothic+One&display=swap'); +:root{ + --default: #ffffff60; + --hover-fg: #fff; + --hover-bg: 1.05; +} + +.light-theme { + --default: #00000080; + --hover-fg: #000; + --hover-bg: 0.8; +} + +.TitlebarWin { + /* -webkit-app-region: drag; */ +} + +.TitlebarWin-drag-region { + top: 0; + left: 0; + display: block; + position: absolute; + width: 100%; + height: 100%; + z-index: -1; + -webkit-app-region: drag; +} + +.resizer { + -webkit-app-region: no-drag; + position: absolute; + top: 0; + width: 100%; + height: 20%; +} + +.Title-Bar { + top: 0px; + width: 100%; + height: 30px; + background-color:transparent; + display: flex; + font-family: 'Open Sans', sans-serif; + position: relative; +} + +.Title-Bar-inactive { + top: 0px; + width: 100%; + height: 30px; + /* background-color: #80ff00; */ + background-color: transparent; + display: flex; + font-family: 'Open Sans', sans-serif; + position: relative; +} + +.Title-Bar__section-icon { + display: flex; + flex-direction: row; + align-items: center; + width: 120px; + position: relative; + /* background-color: rgb(248, 14, 178); */ + font-family: 'Pathway Gothic One', sans-serif; +} + +.section-icon__logo { + padding-left: 5px; + padding-top: 4px; + color: rgba(255, 255, 255, 0.3); +} + +.section-icon__title { + filter: brightness(0.5); + font-size: 17px; + padding-top: 6px; + padding-left: 24px; +} + +.Title-Bar__section-menubar { + display: flex; + flex-direction: row; + align-items: center; + width: 165px; + /* background-color: rgb(49, 14, 248); */ +} + +.section-menubar_button { + padding-left: 10px; + padding-right: 10px; + /* background-color: purple; */ +} + +.Title-Bar__section-center { + /* background-color: rgb(157, 255, 0); */ + display: flex; + flex-direction: row; + flex: 1; + align-items: center; +} + +.TitlebarWin .section-windows-control_box:hover { + backdrop-filter: brightness(var(--hover-bg)) !important; +} + +.Title-Bar-Win__section-windows-control { + /* background-color: green; */ + display: flex; + flex-direction: row; + align-items: center; + width: 135px; + /* needs no-drag for color changing div*/ + -webkit-app-region: no-drag; +} + + +[class^="section-windows-control_box-"]{ + width: 45px; + height: 30px; + /* background-color: purple; */ + display: flex; + align-items: center; + justify-content: center; + transition: all 0.05s ease-in; +} + +.section-windows-control_box-min:hover, +.section-windows-control_box-unmax:hover, +.section-windows-control_box-max:hover { + backdrop-filter: brightness(var(--hover-bg)); +} + +.section-windows-control_box-close:hover { + background-color: rgba(255, 0, 0, 0.3); +} + +.TitlebarWin svg path, +.TitlebarWin svg rect, +.TitlebarWin svg polygon { + fill: var(--default); + /* stroke: #ffffff40; + stroke-width: 1; */ +} + +.section-windows-control_box-min:hover svg rect, +.section-windows-control_box-unmax:hover svg path, +.section-windows-control_box-max:hover svg path{ + fill: var(--hover-fg); +} + +.section-windows-control_box-close:hover svg polygon { + fill: #fff; +} + +.TitlebarWin svg { + width: 10px; + height: 10px; +} \ No newline at end of file diff --git a/client/src/components/lib/Titlebar/TitlebarWindows.tsx b/client/src/components/lib/Titlebar/TitlebarWindows.tsx new file mode 100644 index 0000000..4cec260 --- /dev/null +++ b/client/src/components/lib/Titlebar/TitlebarWindows.tsx @@ -0,0 +1,161 @@ +import React, { useCallback } from 'react'; +import { ipcAPI } from '@/data/ipc/ipcMessages'; +import './TitlebarWindows.css'; +import { useWebSocketStore } from '@/stores/WebSocketStore'; +import { useAuthStore } from '@/stores/AuthStore'; +import { useAppStore } from '@/stores/AppStore'; +import { useElectronIPCStore } from '@/stores/ElectronIPCStore'; + +/** + * Defines the titlebar for the Windows platform. + * @author Yousef Yassin + */ + +const TitlebarWin = ({ title, fg }: { title: string; fg: string }) => { + const { isTransparent } = useAppStore(['isTransparent']); + const { socket, roomID } = useWebSocketStore(['socket', 'roomID']); + const { userEmail: userId } = useAuthStore(['userEmail']); + const { isWindowActive, isWindowMaximized } = useElectronIPCStore([ + 'isWindowActive', + 'isWindowMaximized', + ]); + + const minimizeHandler = () => { + ipcAPI.minimize('minimize'); + }; + + const maximizeHandler = () => { + ipcAPI.maximize('maximize'); + }; + + const unmaximizeHandler = () => { + ipcAPI.unmaximize('unmaximize'); + }; + + const closeHandler = useCallback(() => { + // Need to wait for the message to send. + socket?.leaveRoom().then(() => { + ipcAPI.close('close'); + }); + // HACK: We need to listen to these to get the updated socket since + // the socket is mutable, it won't trigger rerenders on change. + }, [socket, userId, roomID]); + + return ( +
+
+
+
+
{title}
+
+
+
+
+
+ + + +
+ {isWindowMaximized ? ( +
+ + + +
+ ) : ( +
+ + + +
+ )} +
+ + + +
+
+
+
+
+ ); +}; + +export default TitlebarWin; diff --git a/client/src/components/lib/ToolBar.tsx b/client/src/components/lib/ToolBar.tsx index d5bb673..66b4c7e 100644 --- a/client/src/components/lib/ToolBar.tsx +++ b/client/src/components/lib/ToolBar.tsx @@ -21,6 +21,7 @@ import { generateRandId } from '@/lib/bytes'; import { fileOpen } from '@/lib/fs'; import { injectImageElement } from '@/lib/image'; import { useWebSocketStore } from '@/stores/WebSocketStore'; +import { IS_ELECTRON_INSTANCE } from '@/constants'; /** * This is the toolbar that is displayed on the canvas. @@ -173,7 +174,7 @@ const ToolGroup = ({ * The toolbar that is displayed on the canvas. */ const ToolBar = () => { - const { tool } = useAppStore(['tool']); + const { tool, isTransparent } = useAppStore(['tool', 'isTransparent']); return ( { right: 0, // centering margin: 'auto', - top: '1rem', + top: `calc(1rem + ${ + IS_ELECTRON_INSTANCE && isTransparent ? '30px' : '0px' + })`, width: 'fit-content', }} > diff --git a/client/src/components/lib/TransparencyButton.tsx b/client/src/components/lib/TransparencyButton.tsx new file mode 100644 index 0000000..624e0b3 --- /dev/null +++ b/client/src/components/lib/TransparencyButton.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { CanvasButton } from './CanvasButton'; +import { useAppStore } from '@/stores/AppStore'; + +/** + * Defines a button component for starting/stopping transparency mode. + * @author Yousef Yassin + */ + +const TransparencyIcon = ({ className }: { className: string }) => { + return ( + + + + ); +}; + +/** + * Button component for starting/stopping transparency mode. + */ +const defaultClassName = 'disabled:opacity-40 disabled:cursor-not-allowed'; +const TransparencyButton = () => { + const { isTransparent, setIsTransparent, setPanOffset, setAppZoom } = + useAppStore([ + 'isTransparent', + 'setIsTransparent', + 'setPanOffset', + 'setAppZoom', + ]); + + return ( + { + setPanOffset(0, 0); + setAppZoom(1); + setIsTransparent(!isTransparent); + }} + className={`${defaultClassName} ${ + isTransparent ? 'bg-red-600 enabled:hover:bg-red-400' : 'bg-white' + }`} + tooltip={{ + content: isTransparent + ? 'Exit Transparency Mode' + : 'Enter Transparency Mode', + side: 'top', + sideOffset: 5, + }} + > + + + ); +}; + +export default TransparencyButton; diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..82ed0ab --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/client/src/constants.ts b/client/src/constants.ts index 70a24a9..186ea9e 100644 --- a/client/src/constants.ts +++ b/client/src/constants.ts @@ -2,6 +2,8 @@ * Various constants used throughout the application. */ +import { ipcAPI } from './data/ipc/ipcMessages'; + /** * REST & Websockets */ @@ -68,3 +70,6 @@ export const PERIPHERAL_CODES = { * Misc */ export const EPSILON = 1e-8; +// True if running in electron, false otherwise (ipcAPI is injected by +// electron's main process). +export const IS_ELECTRON_INSTANCE = ipcAPI !== undefined; diff --git a/client/src/data/ipc/ipcMessages.ts b/client/src/data/ipc/ipcMessages.ts new file mode 100644 index 0000000..d5f166c --- /dev/null +++ b/client/src/data/ipc/ipcMessages.ts @@ -0,0 +1,3 @@ +const { ipcAPI } = window; +const ipcRenderer = ipcAPI?.electron?.ipcRenderer; +export { ipcAPI, ipcRenderer }; diff --git a/client/src/global.d.ts b/client/src/global.d.ts index 5d04a2e..d485c13 100644 --- a/client/src/global.d.ts +++ b/client/src/global.d.ts @@ -1,31 +1,10 @@ -/** - * Extensions to the API types for image-blob-reduce compatibility - * with pica: https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848 - */ -declare module 'image-blob-reduce' { - import { PicaResizeOptions, Pica } from 'pica'; - namespace ImageBlobReduce { - interface ImageBlobReduce { - toBlob(file: File, options: ImageBlobReduceOptions): Promise; - _create_blob( - this: { pica: Pica }, - env: { - out_canvas: HTMLCanvasElement; - out_blob: Blob; - }, - ): Promise; - } +import { ElectronAPI } from '@electron-toolkit/preload'; - interface ImageBlobReduceStatic { - new (options?: unknown): ImageBlobReduce; - - (options?: unknown): ImageBlobReduce; - } - - interface ImageBlobReduceOptions extends PicaResizeOptions { - max: number; - } +declare global { + // Electron APIs injected in preload.ts + interface Window { + ipcAPI: Record unknown> & { + electron: ElectronAPI; + }; } - const reduce: ImageBlobReduce.ImageBlobReduceStatic; - export = reduce; } diff --git a/client/src/hooks/useIPCListener.tsx b/client/src/hooks/useIPCListener.tsx new file mode 100644 index 0000000..4b0d2f0 --- /dev/null +++ b/client/src/hooks/useIPCListener.tsx @@ -0,0 +1,48 @@ +import { ipcRenderer } from '@/data/ipc/ipcMessages'; +import { useElectronIPCStore } from '@/stores/ElectronIPCStore'; +import { useEffect } from 'react'; + +/** + * @file React hook for listening to Electron IPC events and updating corresponding state in the store. + * @author Yousef Yassin + */ + +const useIPCListener = () => { + const { + setIsWindowActive, + setIsWindowMaximized, + setIsWindowClickThrough, + // setWindowBounds, + } = useElectronIPCStore([ + 'setIsWindowActive', + 'setIsWindowMaximized', + 'setIsWindowClickThrough', + // 'setWindowBounds', + ]); + useEffect(() => { + const listeners = { + focused: () => setIsWindowActive(true), + blurred: () => setIsWindowActive(false), + maximized: () => setIsWindowMaximized(true), + unmaximized: () => setIsWindowMaximized(false), + // Unused for now + // ['bounds-changed']: ( + // _event: unknown, + // { x, y, height, width }: Electron.Rectangle, + // ) => setWindowBounds(x, y, width, height), + ['click-through']: (_event: unknown, clickThrough: boolean) => + setIsWindowClickThrough(clickThrough), + }; + Object.entries(listeners).forEach(([handle, callback]) => + ipcRenderer.on(handle, callback), + ); + // Cleanup + return () => { + Object.keys(listeners).forEach((handle) => + ipcRenderer.removeAllListeners(handle), + ); + }; + }, []); +}; + +export default useIPCListener; diff --git a/client/src/hooks/useShortcut.tsx b/client/src/hooks/useShortcut.tsx index 5202cfd..7f37c68 100644 --- a/client/src/hooks/useShortcut.tsx +++ b/client/src/hooks/useShortcut.tsx @@ -1,4 +1,5 @@ import { ZOOM } from '@/constants'; +import { ipcAPI } from '@/data/ipc/ipcMessages'; import { clamp } from '@/lib/misc'; import { useAppStore } from '@/stores/AppStore'; import { useCanvasElementStore } from '@/stores/CanvasElementsStore'; @@ -106,6 +107,12 @@ export const useShortcuts = () => { } break; } + // Test notification + case 'KeyR': { + if (e.ctrlKey) { + ipcAPI.notification({ title: 'Test', body: 'Test Body' }); + } + } } }; diff --git a/client/src/hooks/useWindowResize.tsx b/client/src/hooks/useWindowResize.tsx index bfa639a..4465e79 100644 --- a/client/src/hooks/useWindowResize.tsx +++ b/client/src/hooks/useWindowResize.tsx @@ -1,7 +1,10 @@ +import { IS_ELECTRON_INSTANCE } from '@/constants'; import { useAppStore } from '@/stores/AppStore'; import { EVENT } from '@/types'; import { useEffect } from 'react'; +const titleBarOffset = IS_ELECTRON_INSTANCE ? 30 : 0; + /** * Hook that's subscribed to resize events to * update app dimension state for resize handling. @@ -10,11 +13,12 @@ import { useEffect } from 'react'; const useWindowResize = () => { const { setAppDimensions } = useAppStore(['setAppDimensions']); const handleResize = () => { - setAppDimensions(window.innerWidth, window.innerHeight); + setAppDimensions(window.innerWidth, window.innerHeight - titleBarOffset); }; useEffect(() => { window.addEventListener(EVENT.RESIZE, handleResize); + handleResize(); return () => window.removeEventListener(EVENT.RESIZE, handleResize); }, []); }; diff --git a/client/src/hooks/webrtc/useRTCConsumer.tsx b/client/src/hooks/webrtc/useRTCConsumer.tsx index f2ce95f..45f12ba 100644 --- a/client/src/hooks/webrtc/useRTCConsumer.tsx +++ b/client/src/hooks/webrtc/useRTCConsumer.tsx @@ -94,7 +94,9 @@ const useRTCConsumer = ( } peerRef.current = peer; // On incoming stream, set the video element's srcObject to the stream. - peer.ontrack = (e) => setScreenStream(e.streams[0]); + peer.ontrack = (e) => { + setScreenStream(e.streams[0]); + }; // Only listen for video tracks peer.addTransceiver('video', { direction: 'recvonly' }); }; diff --git a/client/src/hooks/webrtc/useRTCProducer.tsx b/client/src/hooks/webrtc/useRTCProducer.tsx index 4e9a137..70a418f 100644 --- a/client/src/hooks/webrtc/useRTCProducer.tsx +++ b/client/src/hooks/webrtc/useRTCProducer.tsx @@ -3,7 +3,8 @@ import { createPeer, handleNegotiationNeededEvent } from '@/lib/webrtc'; import { useAppStore } from '@/stores/AppStore'; import { useAuthStore } from '@/stores/AuthStore'; import { useWebSocketStore } from '@/stores/WebSocketStore'; -import { useEffect, useRef } from 'react'; +import { StreamSource } from '@/types'; +import { useEffect, useRef, useState } from 'react'; /** * Hook that handles creating a producer if a stream is started inside a room. @@ -18,6 +19,8 @@ import { useEffect, useRef } from 'react'; const useRTCProducer = ( screenStream: MediaStream | null, setScreenStream: (stream: MediaStream | null) => void, + setScreenSelectDialogOpen: (value: boolean) => void, + setStreamSources: (streamSources: StreamSource[]) => void, ) => { const peerRef = useRef(); const mediaRecorderRef = useRef(null); @@ -27,7 +30,7 @@ const useRTCProducer = ( ]); const { socket, roomID } = useWebSocketStore(['socket', 'roomID']); const { userEmail: userId } = useAuthStore(['userEmail']); - + const [selectedSourceId, setSelectedSourceId] = useState(null); useEffect(() => { // Cleanup on component unmount return () => { @@ -52,31 +55,42 @@ const useRTCProducer = ( if (isSharingScreen) { initProducer(); } else { - cleanup(); + // Recorder on stop will handle cleanup stopScreenShare(); + cleanup(); } }, [isSharingScreen]); + useEffect(() => { + if (selectedSourceId !== null) { + setStreamFromId(selectedSourceId); + } + }, [selectedSourceId]); /** * Initializes a screenshare recorder, and producer for sharing screen streams. */ const initProducer = async () => { - if (!isSharingScreen) { + // If we're using electron then we set the sharing screen + // state after already initializing the producer, in which + // case there will be a selected source id. + if (!isSharingScreen || selectedSourceId !== null) { return; } // Start the screenshare. - const { stream, recorder } = await startScreenShare( + const { stream, recorder } = ((await startScreenShare( setScreenStream, async () => { setScreenStream(null); setIsSharingScreen(false); - await socket?.rtcEnd(); - peerRef.current?.close(); - peerRef.current = undefined; + cleanup(); }, - ); + (streamSources) => { + setScreenSelectDialogOpen(true); + setStreamSources(streamSources); + }, + )) ?? {}) as unknown as { stream: MediaStream; recorder: MediaRecorder }; if (stream === undefined || recorder === undefined) { - console.log('canceled'); + console.log('pending or cancelled'); setIsSharingScreen(false); return; } @@ -121,10 +135,39 @@ const useRTCProducer = ( /** * Clean up resources when the component unmounts or the room changes. */ - const cleanup = () => { - peerRef.current?.close(); - peerRef.current = undefined; - stopScreenShare(); + const cleanup = async () => { + if (peerRef.current !== undefined) { + await socket?.rtcEnd(); + peerRef.current?.close(); + peerRef.current = undefined; + stopScreenShare(); + setSelectedSourceId(null); + } + }; + + /** + * In Electron, the streamId will be set by the alert dialog. + * On set, we can fetch the media stream from the desktop capture API + * using getDisplayMedia and start the stream this way. + * @param streamId The Id of the stream to fetch. + */ + const setStreamFromId = async (streamId: string) => { + // Overriden by electron in renderer.js + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const stream = await navigator.mediaDevices.getDisplayMedia(streamId); + setScreenStream(stream); + // Create the producer peer. + const peer = createProducer(); + if (peer === undefined) { + setIsSharingScreen(false); + return; + } + + peerRef.current = peer; + // TODO: Store senders with labels in ref + stream.getTracks().forEach((track) => peer.addTrack(track, stream)); + setIsSharingScreen(true); }; /** @@ -148,7 +191,7 @@ const useRTCProducer = ( setIsSharingScreen(false); }; - return peerRef; + return { peerRef, setSelectedSourceId }; }; export default useRTCProducer; diff --git a/client/src/imageBlobReduce.d.ts b/client/src/imageBlobReduce.d.ts new file mode 100644 index 0000000..5d04a2e --- /dev/null +++ b/client/src/imageBlobReduce.d.ts @@ -0,0 +1,31 @@ +/** + * Extensions to the API types for image-blob-reduce compatibility + * with pica: https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848 + */ +declare module 'image-blob-reduce' { + import { PicaResizeOptions, Pica } from 'pica'; + namespace ImageBlobReduce { + interface ImageBlobReduce { + toBlob(file: File, options: ImageBlobReduceOptions): Promise; + _create_blob( + this: { pica: Pica }, + env: { + out_canvas: HTMLCanvasElement; + out_blob: Blob; + }, + ): Promise; + } + + interface ImageBlobReduceStatic { + new (options?: unknown): ImageBlobReduce; + + (options?: unknown): ImageBlobReduce; + } + + interface ImageBlobReduceOptions extends PicaResizeOptions { + max: number; + } + } + const reduce: ImageBlobReduce.ImageBlobReduceStatic; + export = reduce; +} diff --git a/client/src/index.css b/client/src/index.css index 00f62bf..836dbb4 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -72,6 +72,6 @@ } body { - @apply bg-background text-foreground; + @apply text-foreground; } } \ No newline at end of file diff --git a/client/src/lib/screenshare.ts b/client/src/lib/screenshare.ts index 1ce059b..2085406 100644 --- a/client/src/lib/screenshare.ts +++ b/client/src/lib/screenshare.ts @@ -3,13 +3,16 @@ * @author Yousef Yassin */ +import { ipcAPI } from '@/data/ipc/ipcMessages'; +import { StreamSource } from '@/types'; + /** * Function to initiate screen sharing, capturing the user's screen and creating a MediaRecorder instance. * @param setScreenStream Callback function to set the captured screen stream. * @param onRecoderStop Callback function to be executed when the MediaRecorder stops recording. * @returns A promise resolving to an object containing the captured screen stream and MediaRecorder instance. */ -export const startScreenShare = async ( +const startScreenShareBrowser = async ( setScreenStream: (stream: MediaStream) => void, onRecoderStop: () => void, ) => { @@ -50,3 +53,30 @@ export const startScreenShare = async ( return {}; } }; + +/** + * Function to initiate screen sharing for electron. This consists of two phases. Here, we fetch + * all the available input sources and prompt the user with a dialog with onScreenSelect. Selection + * in this dialog will trigger the second phase by setting the sourceId in the RTCProducer hook. + * @param onScreenSelect Callback function to open a dialog with the available input sources. + * Note that the first two parameters are unused, they are defined to maintain signature. + */ +const startScreenShareElectron = async ( + _setScreenStream: (stream: MediaStream) => void, + _onRecoderStop: () => void, + onScreenSelect: (streamSources: StreamSource[]) => void, +) => { + try { + // Defined in electron + const streams = + (await navigator.mediaDevices.enumerateDevices()) as unknown as StreamSource[]; + onScreenSelect(streams); + } catch (error) { + console.error('Error starting screen share:', error); + return {}; + } +}; + +export const startScreenShare = ipcAPI + ? startScreenShareElectron + : startScreenShareBrowser; diff --git a/client/src/stores/AppStore.ts b/client/src/stores/AppStore.ts index 3a9748f..d6130d2 100644 --- a/client/src/stores/AppStore.ts +++ b/client/src/stores/AppStore.ts @@ -35,6 +35,8 @@ interface AppState { panOffset: { x: number; y: number }; // Canvas Background Color canvasColor: string; + // Whether app is in transparent canvas mode. + isTransparent: boolean; } /** Reducers */ interface AppActions { @@ -49,6 +51,7 @@ interface AppActions { setIsSharingScreen: (isShareScreen: boolean) => void; setIsInCall: (isInCall: boolean) => void; setCanvasBackground: (canvasColor: string) => void; + setIsTransparent: (isTransparent: boolean) => void; } type AppStore = AppState & AppActions; @@ -66,6 +69,7 @@ export const initialAppState: AppState = { zoom: 1, // 100% panOffset: { x: 0, y: 0 }, canvasColor: '#fff', + isTransparent: false, }; /** Actions / Reducers */ @@ -88,12 +92,23 @@ const setIsSharingScreen = set(() => ({ isSharingScreen })); const setIsInCall = (set: SetState) => (isInCall: boolean) => set(() => ({ isInCall })); +const setIsTransparent = + (set: SetState) => (isTransparent: boolean) => + set(() => ({ isTransparent })); +// Zoom and Panning are disabled in transparent mode. const setAppZoom = (set: SetState) => (zoom: number) => - set(() => ({ - zoom, - })); + set((state) => + state.isTransparent + ? state + : { + ...state, + zoom, + }, + ); const setPanOffset = (set: SetState) => (x: number, y: number) => - set(() => ({ panOffset: { x, y } })); + set((state) => + state.isTransparent ? state : { ...state, panOffset: { x, y } }, + ); const setCanvasBackground = (set: SetState) => (canvasColor: string) => set(() => ({ canvasColor })); @@ -112,5 +127,6 @@ const appStore = create()((set) => ({ setPanOffset: setPanOffset(set), setIsInCall: setIsInCall(set), setCanvasBackground: setCanvasBackground(set), + setIsTransparent: setIsTransparent(set), })); export const useAppStore = createStoreWithSelectors(appStore); diff --git a/client/src/stores/ElectronIPCStore.ts b/client/src/stores/ElectronIPCStore.ts new file mode 100644 index 0000000..2b020d3 --- /dev/null +++ b/client/src/stores/ElectronIPCStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; +import { SetState } from './types'; +import { createStoreWithSelectors } from './utils'; + +/** Definitions */ +interface ElectronIPCState { + isWindowActive: boolean; + isWindowMaximized: boolean; + isWindowClickThrough: boolean; + windowBounds: { + x: number; + y: number; + width: number; + height: number; + }; +} + +interface ElectronIPCActions { + setIsWindowActive: (isWindowActive: boolean) => void; + setIsWindowMaximized: (isWindowMaximized: boolean) => void; + setIsWindowClickThrough: (isWindowClickThrough: boolean) => void; + setWindowBounds: ( + x: number, + y: number, + width: number, + height: number, + ) => void; +} + +type ElectronIPCStore = ElectronIPCActions & ElectronIPCState; + +// Initialize Auth State to default state. +export const initialElectronIPCState: ElectronIPCState = { + isWindowActive: false, + isWindowMaximized: false, + isWindowClickThrough: false, + windowBounds: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, +}; + +/** Actions / Reducers */ +const setIsWindowActive = + (set: SetState) => (isWindowActive: boolean) => + set(() => ({ isWindowActive })); +const setIsWindowMaximized = + (set: SetState) => (isWindowMaximized: boolean) => + set(() => ({ isWindowMaximized })); +const setIsWindowClickThrough = + (set: SetState) => (isWindowClickThrough: boolean) => + set(() => ({ isWindowClickThrough })); +const setWindowBounds = + (set: SetState) => + (x: number, y: number, width: number, height: number) => + set(() => ({ windowBounds: { x, y, width, height } })); + +/** Store Hook */ +const ElectronIPCStore = create()((set) => ({ + ...initialElectronIPCState, + setIsWindowActive: setIsWindowActive(set), + setIsWindowMaximized: setIsWindowMaximized(set), + setIsWindowClickThrough: setIsWindowClickThrough(set), + setWindowBounds: setWindowBounds(set), +})); +export const useElectronIPCStore = createStoreWithSelectors(ElectronIPCStore); diff --git a/client/src/types.ts b/client/src/types.ts index 0d49b99..c845791 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -151,3 +151,17 @@ export type BinaryFileData = { */ lastRetrieved?: number; }; + +// Electron Media stream source. +export interface StreamSource { + name: string; + id: string; + thumbnail: { + dataURL: string; + aspect: number; + }; + display_id: string; + appIcon: { + dataURL: string; + }; +} diff --git a/client/src/views/SignInPage.tsx b/client/src/views/SignInPage.tsx index 0799eea..dfbce57 100644 --- a/client/src/views/SignInPage.tsx +++ b/client/src/views/SignInPage.tsx @@ -140,7 +140,7 @@ export default function SignInPage() { }; return ( - + Log In diff --git a/client/src/views/SignUpPage.tsx b/client/src/views/SignUpPage.tsx index f637a9c..8c75e54 100644 --- a/client/src/views/SignUpPage.tsx +++ b/client/src/views/SignUpPage.tsx @@ -182,7 +182,7 @@ export default function SignUp() { >(null); return ( - + Create an account diff --git a/client/src/views/Viewport.tsx b/client/src/views/Viewport.tsx index 2d341e6..188fa71 100644 --- a/client/src/views/Viewport.tsx +++ b/client/src/views/Viewport.tsx @@ -12,6 +12,8 @@ import ContextMenu from '@/components/lib/ContextMenu'; import * as RadixContextMenu from '@radix-ui/react-context-menu'; import ShareScreen from '@/components/lib/ShareScreen'; import ShareScreenButton from '@/components/lib/ShareScreenButton'; +import TransparencyButton from '@/components/lib/TransparencyButton'; +import { IS_ELECTRON_INSTANCE } from '@/constants'; /** * Primary viewport that houses the canvas @@ -20,13 +22,23 @@ import ShareScreenButton from '@/components/lib/ShareScreenButton'; * @authors Yousef Yassin */ const Viewport = () => { - const { setMode } = useAppStore(['setMode']); + const { setMode, isTransparent } = useAppStore(['setMode', 'isTransparent']); const viewportRef = useRef(null); const { selectedElementIds } = useCanvasElementStore(['selectedElementIds']); return ( -
+
{/* Only show the toolbar is an element is selected */} {selectedElementIds.length === 1 && } @@ -44,6 +56,7 @@ const Viewport = () => { + {IS_ELECTRON_INSTANCE && }
{/* Temp */} @@ -51,7 +64,9 @@ const Viewport = () => { style={{ position: 'absolute', left: '1rem', - top: '1rem', + top: `calc(1rem + ${ + IS_ELECTRON_INSTANCE && isTransparent ? '30px' : '0px' + })`, }} onClick={() => setMode('dashboard')} > diff --git a/node/src/lib/webrtc/RoomSFU.ts b/node/src/lib/webrtc/RoomSFU.ts index 2f6c79c..46e1457 100644 --- a/node/src/lib/webrtc/RoomSFU.ts +++ b/node/src/lib/webrtc/RoomSFU.ts @@ -21,17 +21,19 @@ export class RoomSFU { #consumers: Record; #roomId: string; #logger: Logger; + #onCleanup: (id: string) => void; /** * Creates an SFU for the specified room. * @param roomId The unique identifier for the room. * @param logger The logger instance for logging room-specific events. */ - constructor(roomId: string, logger: Logger) { + constructor(roomId: string, logger: Logger, onCleanup: (id: string) => void) { this.#producer = null; this.#consumers = {} as Record; this.#roomId = roomId; this.#logger = logger.deriveLogger(`Room-${truncateString(roomId, 5)}`); + this.#onCleanup = onCleanup; } /** @@ -135,7 +137,10 @@ export class RoomSFU { this.#roomId, this.#logger, // OnClose Cleanup - () => this.removeProducer(), + () => { + this.#logger.debug(`Producer [${id}] peer disconnected`); + this.#onCleanup(id); + }, ); return localDescription; }; @@ -175,7 +180,10 @@ export class RoomSFU { this.#roomId, this.#logger, // OnClose Cleanup - () => this.removeConsumer(id), + () => { + this.#logger.debug(`Consumer [${id}] peer disconnected`); + this.#onCleanup(id); + }, ); // Only add the consumer after negotiation (to prevent adding ice candidates before acquiring the local description) this.#consumers[id] = consumer; @@ -213,16 +221,20 @@ export class RoomSFU { * @returns A boolean indicating whether the producer was successfully removed. */ removeProducer = () => { + this.#logger.debug(`Removing producer [${this.#producer?.id}] from room`); if (this.#producer === null) { - this.#logger.error( - "Attempted to remove producer from room but there isn't one. This should never happen.", + this.#logger.debug( + "Attempted to remove producer from room but there isn't one. Ignore if cleanup.", ); return false; } // Notify all consumers to disconnect - Object.entries(websocketManager.sockets[this.#roomId]).forEach( - ([socketId, socket]) => { + const sockets = websocketManager.sockets[this.#roomId]; + if (sockets === undefined) { + this.#logger.error(`Failed to get sockets for room [${this.#roomId}]`); + } else { + Object.entries(sockets).forEach(([socketId, socket]) => { if (socketId !== this.#producer?.id) { socket.send( JSON.stringify({ @@ -232,8 +244,9 @@ export class RoomSFU { this.#logger.debug(`Removed consumer [${socketId}] from room`); this.removeConsumer(socketId); } - }, - ); + }); + } + // Remove all the consumer peers on the server cleanly if (Object.keys(this.#consumers).length !== 0) { this.#logger.error(`Found stale consumers in room [${this.#roomId}]`); diff --git a/node/src/lib/webrtc/SFUManager.ts b/node/src/lib/webrtc/SFUManager.ts index 7a46dc7..b492e28 100644 --- a/node/src/lib/webrtc/SFUManager.ts +++ b/node/src/lib/webrtc/SFUManager.ts @@ -15,7 +15,7 @@ const { sendErrorResponse, sendSuccessResponse } = helpers; /** * SFUManager Singleton Class - * Manages instances of RoomSFU for each room, coordinating producers and consumers. + * Manages instances of RoomSFU for each room to coordinate producers and consumers. */ export class SFUManager extends Singleton() { #SFUs = {} as Record; @@ -129,9 +129,11 @@ export class SFUManager extends Singleton() { return null; } - const roomSFU = new RoomSFU(roomId, this.#logger); + const roomSFU = new RoomSFU(roomId, this.#logger, (id) => + this.remove(id, roomId), + ); const localDescription = await roomSFU.addProducer(id, sdp); - this.#logger.debug( + this.#logger.info( `Added producer [${id}] to room [${roomId}]: ${ localDescription !== null }.`, @@ -160,7 +162,7 @@ export class SFUManager extends Singleton() { return null; } const localDescription = roomSFU.addConsumer(id, sdp); - this.#logger.debug( + this.#logger.info( `Added consumer [${id}] to room [${roomId}]: ${ localDescription !== null }.`, @@ -184,7 +186,7 @@ export class SFUManager extends Singleton() { } const success = roomSFU.removeProducer(); delete this.#SFUs[roomId]; - this.#logger.debug( + this.#logger.info( `Removed producer [${id}] from room [${roomId}]: ${success}`, ); return success; @@ -205,7 +207,7 @@ export class SFUManager extends Singleton() { return false; } const success = roomSFU.removeConsumer(id); - this.#logger.debug( + this.#logger.info( `Removed consumer [${id}] from room [${roomId}]: ${success}`, ); return success;