├── .gitignore ├── icon.png ├── .DS_Store ├── icon.icns ├── icon-idle.png ├── icon-recording.png ├── .vscode ├── extensions.json └── settings.json ├── .prettierrc ├── config.js ├── .deepsource.toml ├── config.template.js ├── .github └── workflows │ └── build.yml ├── LICENSE ├── package.json ├── camera.html ├── README.md ├── index.html ├── main.js └── renderer.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .env 3 | node_modules -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goshops-com/clipshare/HEAD/icon.png -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goshops-com/clipshare/HEAD/.DS_Store -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goshops-com/clipshare/HEAD/icon.icns -------------------------------------------------------------------------------- /icon-idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goshops-com/clipshare/HEAD/icon-idle.png -------------------------------------------------------------------------------- /icon-recording.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goshops-com/clipshare/HEAD/icon-recording.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode" 4 | ] 5 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": true 6 | } 7 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | accessKey: 'undefined', 3 | accessSecret: 'undefined', 4 | endpoint: 'undefined', 5 | region: 'undefined', 6 | }; 7 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "javascript" 5 | 6 | [analyzers.meta] 7 | environment = [ 8 | "nodejs", 9 | "browser" 10 | ] -------------------------------------------------------------------------------- /config.template.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | accessKey: '%%ACCESS_KEY%%', 3 | accessSecret: '%%ACCESS_SECRET%%', 4 | endpoint: '%%ENDPOINT%%', 5 | region: '%%REGION%%', 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.tabSize": 2, 5 | "editor.insertSpaces": true, 6 | "editor.detectIndentation": false 7 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: push 4 | 5 | jobs: 6 | release: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [macos-latest, ubuntu-latest, windows-latest] 12 | 13 | steps: 14 | - name: Check out Git repository 15 | uses: actions/checkout@v1 16 | 17 | - name: Install Node.js, NPM and Yarn 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 22 21 | 22 | - name: Build/release Electron app 23 | env: 24 | MAC_ARGS: ${{ matrix.os == 'macos-latest' && '--x64 --arm64 --universal' || '' }} 25 | uses: samuelmeuli/action-electron-builder@v1 26 | with: 27 | # GitHub token, automatically provided to the action 28 | # (No need to define this secret in the repo settings) 29 | github_token: ${{ secrets.github_token }} 30 | 31 | args: ${{ env.MAC_ARGS}} 32 | 33 | # If the commit is tagged with a version (e.g. "v1.0.0"), 34 | # release the app after building 35 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clipshare", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "electron .", 9 | "build": "node build.js", 10 | "release": "electron-builder" 11 | }, 12 | "build": { 13 | "appId": "com.gopersonal.clipshare", 14 | "mac": { 15 | "target": "dmg", 16 | "hardenedRuntime": true, 17 | "gatekeeperAssess": false, 18 | "entitlements": "build/entitlements.mac.plist", 19 | "entitlementsInherit": "build/entitlements.mac.plist", 20 | "extendInfo": { 21 | "NSMicrophoneUsageDescription": "Please give us access to your microphone", 22 | "NSCameraUsageDescription": "Please give us access to your camera", 23 | "com.apple.security.device.audio-input": true, 24 | "com.apple.security.device.camera": true 25 | } 26 | }, 27 | "dmg": { 28 | "contents": [ 29 | { 30 | "x": 410, 31 | "y": 150, 32 | "type": "link", 33 | "path": "/Applications" 34 | }, 35 | { 36 | "x": 130, 37 | "y": 150, 38 | "type": "file" 39 | } 40 | ], 41 | "window": { 42 | "width": 540, 43 | "height": 380 44 | } 45 | }, 46 | "win": { 47 | "target": "nsis", 48 | "requestedExecutionLevel": "requireAdministrator" 49 | }, 50 | "linux": { 51 | "target": "AppImage", 52 | "category": "Utility", 53 | "publish": [ 54 | "github" 55 | ] 56 | }, 57 | "files": [ 58 | ".env", 59 | "**/*", 60 | "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}", 61 | "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}", 62 | "!**/node_modules/*.d.ts", 63 | "!**/node_modules/.bin", 64 | "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}", 65 | "!.editorconfig", 66 | "!**/._*", 67 | "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}", 68 | "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}", 69 | "!**/{appveyor.yml,.travis.yml,circle.yml}", 70 | "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}" 71 | ], 72 | "protocols": { 73 | "name": "clipshare-protocol", 74 | "schemes": [ 75 | "clipshare" 76 | ] 77 | } 78 | }, 79 | "keywords": [], 80 | "author": "", 81 | "license": "ISC", 82 | "devDependencies": { 83 | "electron": "^31.4.0", 84 | "electron-builder": "^24.13.3", 85 | "electron-packager": "^17.1.2" 86 | }, 87 | "dependencies": { 88 | "auto-launch": "^5.0.6", 89 | "aws-sdk": "^2.1677.0", 90 | "dotenv": "^16.4.5", 91 | "ulid": "^2.3.0" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /camera.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Camera Feed 7 | 46 | 47 | 48 | 49 |
50 |
51 | 52 |
53 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ClipShare 2 | 3 | **ClipShare** is a serverless screen recording application built as an open-source alternative to Loom. It empowers users to effortlessly record their screens, optionally capture camera footage, and seamlessly upload the recordings to an S3-compatible storage service. Powered by Electron, ClipShare offers a cross-platform solution that's both powerful and easy to deploy. 4 | 5 | ![ClipShare Logo](https://github.com/goshops-com/clipshare/blob/main/icon.png?raw=true) 6 | 7 | ## 🎥 Video Demo 8 | 9 | Check out [ClipShare in action](https://clipshare.gopersonal.com/01J64TT8C7BJD6G37SB7AP203B.webm). 10 | 11 | ## 🚀 Features 12 | 13 | - **📹 Screen Recording**: Capture your entire screen or specific windows with crystal-clear audio. 14 | - **🎥 Camera Integration**: Add a personal touch by including your camera feed in recordings. 15 | - **🚀 Auto-Launch**: Start ClipShare automatically when your system boots up. 16 | - **🖥️ Tray Icon**: Quick access to ClipShare from your system tray for seamless workflow integration. 17 | - **☁️ Serverless Architecture**: Upload recordings directly to an S3 bucket without the need for a backend server. 18 | - **🛠️ Customizable**: Easily configure recording settings, storage options, and more. 19 | 20 | ## 🏁 Getting Started 21 | 22 | ### Prerequisites 23 | 24 | Before you begin, ensure you have the following installed: 25 | 26 | - [Node.js](https://nodejs.org/) (v14 or higher) 27 | - [npm](https://www.npmjs.com/) (v6 or higher) 28 | - An S3-compatible storage service (e.g., AWS S3, MinIO, DigitalOcean Spaces) 29 | 30 | ### Installation 31 | 32 | 1. Clone the repository: 33 | 34 | 2. Install the dependencies: 35 | 36 | ```bash 37 | npm install 38 | ``` 39 | 40 | 3. Create a `.env` file in the root directory: 41 | 42 | ```plaintext 43 | ACCESS_KEY=your_access_key 44 | ACCESS_SECRET=your_secret_key 45 | ENDPOINT=https://s3.yourservice.com 46 | REGION=us-east-1 47 | BUCKET_NAME=your-bucket-name 48 | URL_PREFIX=https://your-custom-domain.com/ 49 | ACL=public-read 50 | PRESIGN_URL=false 51 | PRESIGN_URL_EXPIRY=3600 52 | ``` 53 | 54 | Notes: 55 | 56 | * `URL_PREFIX` is optional. If provided, it will be used as a prefix for the uploaded file URLs. 57 | * `ACL` is optional; default is `public-read`. Other values, such as `private` or `authenticated-read`, may be configured, depending on your object storage provider. This value will be used as the canned ACL when saving recordings. 58 | * `PRESIGN_URL` is optional; default is `false`. If set to `true`, recording URL will be signed, allowing use of a private bucket. 59 | * `PRESIGN_URL_EXPIRY` is optional; default is 86400 (1 day). This value is the expiry for the signed URL, in seconds. 60 | 61 | ### Building the Application 62 | 63 | To build ClipShare for your platform: 64 | 65 | ```bash 66 | npm run build 67 | ``` 68 | 69 | This command packages the application and places it in the `dist/` directory. 70 | 71 | ### Running the Application 72 | 73 | For development: 74 | 75 | ```bash 76 | npm start 77 | ``` 78 | 79 | For production, run the packaged application from the `dist/` directory. 80 | 81 | ## 📘 Usage 82 | 83 | 1. **Launch**: Start ClipShare from your applications menu or use auto-launch. 84 | 2. **Access**: Click the tray icon to open the main interface. 85 | 3. **Record**: Choose your recording options and click "Start Recording". 86 | 4. **Stop**: Click "Stop Recording" when finished. 87 | 5. **Share**: After automatic upload, use the provided URL to share your recording. 88 | 89 | ## 🏠 Self-Hosted Setup 90 | 91 | If you don't intend to use an external S3 service, you can set up a MinIO docker container for local storage. Here's an example `docker-compose.yml` file: 92 | 93 | ```yaml 94 | version: '3' 95 | services: 96 | minio: 97 | image: minio/minio 98 | ports: 99 | - "9000:9000" 100 | - "9001:9001" 101 | volumes: 102 | - ./data:/data 103 | environment: 104 | MINIO_ROOT_USER: your_access_key 105 | MINIO_ROOT_PASSWORD: your_secret_key 106 | command: server /data --console-address ":9001" 107 | ``` 108 | 109 | To use this setup: 110 | 111 | 1. Save the above content in a `docker-compose.yml` file. 112 | 2. Run `docker-compose up -d` to start the MinIO server. 113 | 3. Access the MinIO console at `http://localhost:9001` and create a bucket. 114 | 4. Update your `.env` file with the following: 115 | 116 | ```plaintext 117 | ACCESS_KEY=your_access_key 118 | ACCESS_SECRET=your_secret_key 119 | ENDPOINT=http://localhost:9000 120 | REGION=us-east-1 121 | BUCKET_NAME=your-bucket-name 122 | ``` 123 | 124 | This configuration allows you to use ClipShare with a self-hosted S3-compatible storage solution. 125 | 126 | ## 🛠️ Troubleshooting 127 | 128 | - **Environment Variables**: Ensure your `.env` file is correctly formatted and in the root directory. 129 | - **Auto-Launch Issues**: Check your system's startup application settings. 130 | - **Recording Quality**: Adjust bitrate and resolution in the app settings for optimal performance. 131 | 132 | ## 🤝 Contributing 133 | 134 | We welcome contributions! Please follow these steps: 135 | 136 | 1. Fork the repository 137 | 2. Create a new branch: `git checkout -b feature/your-feature-name` 138 | 3. Make your changes and commit them: `git commit -m 'Add some feature'` 139 | 4. Push to the branch: `git push origin feature/your-feature-name` 140 | 5. Submit a pull request 141 | 142 | Please read our [Contributing Guidelines](CONTRIBUTING.md) for more details. 143 | 144 | ## 📜 License 145 | 146 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 147 | 148 | ## 🙏 Acknowledgments 149 | 150 | - [Electron](https://www.electronjs.org/) for making cross-platform desktop apps easy 151 | - [AWS SDK](https://aws.amazon.com/sdk-for-javascript/) for S3 integration 152 | - All our amazing contributors and users! 153 | 154 | --- 155 | 156 | Built with ❤️ by the ClipShare team. Happy recording! 157 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Clipshare 7 | 8 | 9 | 22 | 23 | 24 |
25 |
26 |

Clipshare

27 |

from gopersonal.com

28 |
29 | 30 |
31 | 42 | 43 | 49 | 50 | 61 |
62 | 63 |
64 | 71 | 72 | 89 |
90 |
91 | 92 | 104 | 105 | 106 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { 2 | app, 3 | BrowserWindow, 4 | Tray, 5 | Menu, 6 | ipcMain, 7 | desktopCapturer, 8 | systemPreferences, 9 | } = require('electron'); 10 | const path = require('path'); 11 | const AutoLaunch = require('auto-launch'); 12 | const { ulid } = require('ulid'); 13 | const dotenv = require('dotenv'); 14 | 15 | const envPath = path.join(__dirname, '.env'); 16 | dotenv.config({ path: envPath }); 17 | 18 | // Only ask for media access on macOS 19 | if (process.platform === 'darwin') { 20 | systemPreferences.askForMediaAccess('microphone'); 21 | systemPreferences.askForMediaAccess('camera'); 22 | } 23 | 24 | const AWS = require('aws-sdk'); 25 | const IS_WINDOW_MODE = process.env.MODE === 'WINDOW'; 26 | 27 | console.log('IS_WINDOW_MODE', IS_WINDOW_MODE); 28 | 29 | let tray = null; 30 | let window = null; 31 | let mainWindow = null; 32 | 33 | // Ensure single instance 34 | const gotTheLock = app.requestSingleInstanceLock(); 35 | 36 | if (!gotTheLock) { 37 | app.quit(); 38 | return; 39 | } 40 | 41 | // Initialize AutoLaunch 42 | const clipShareAutoLauncher = new AutoLaunch({ 43 | name: 'ClipShare', 44 | path: app.getPath('exe'), 45 | }); 46 | 47 | const s3 = new AWS.S3({ 48 | accessKeyId: process.env.ACCESS_KEY, 49 | secretAccessKey: process.env.ACCESS_SECRET, 50 | endpoint: process.env.ENDPOINT, 51 | signatureVersion: 'v4', 52 | region: process.env.REGION, 53 | }); 54 | 55 | const BUCKET_NAME = process.env.BUCKET_NAME; 56 | const ACL = process.env.ACL || 'public-read'; 57 | const PRESIGN_URL = String(process.env.PRESIGN_URL).toLowerCase() === 'true'; 58 | const PRESIGN_URL_EXPIRY = 59 | parseInt(process.env.PRESIGN_URL_EXPIRY, 10) || 86400; 60 | 61 | // Enable AutoLaunch 62 | clipShareAutoLauncher 63 | .isEnabled() 64 | .then((isEnabled) => { 65 | if (!isEnabled) clipShareAutoLauncher.enable(); 66 | }) 67 | .catch((err) => { 68 | console.error('AutoLaunch enable error:', err); 69 | }); 70 | 71 | function createWindow() { 72 | if (window) { 73 | if (window.isMinimized()) window.restore(); 74 | window.focus(); 75 | return; 76 | } 77 | 78 | window = new BrowserWindow({ 79 | width: 325, 80 | height: 330, 81 | show: IS_WINDOW_MODE, 82 | frame: IS_WINDOW_MODE, 83 | resizable: IS_WINDOW_MODE, 84 | webPreferences: { 85 | nodeIntegration: true, 86 | contextIsolation: false, 87 | enableRemoteModule: true, 88 | webSecurity: false, 89 | }, 90 | }); 91 | 92 | window.loadFile('index.html'); 93 | 94 | if (IS_WINDOW_MODE) { 95 | window.on('close', (event) => { 96 | if (!app.isQuitting) { 97 | event.preventDefault(); 98 | window.hide(); 99 | } 100 | return false; 101 | }); 102 | } 103 | 104 | // Request camera permissions 105 | window.webContents.session.setPermissionRequestHandler( 106 | (webContents, permission, callback) => { 107 | if (permission === 'media') { 108 | callback(true); 109 | } else { 110 | callback(false); 111 | } 112 | } 113 | ); 114 | 115 | // Enable DevTools for debugging 116 | // window.webContents.openDevTools({ mode: 'detach' }); 117 | } 118 | 119 | function handleTrayClick(event, bounds) { 120 | if (!window) return; 121 | 122 | const { x, y } = bounds; 123 | const { height, width } = window.getBounds(); 124 | 125 | const yPosition = process.platform === 'darwin' ? y : y - height; 126 | window.setBounds({ 127 | x: x - width / 2, 128 | y: yPosition, 129 | height, 130 | width, 131 | }); 132 | 133 | if (window.isVisible()) { 134 | console.log('Tray clicked: Hiding window'); 135 | window.hide(); 136 | } else { 137 | console.log('Tray clicked: Showing window'); 138 | window.show(); 139 | window.focus(); 140 | } 141 | } 142 | 143 | function createTray() { 144 | if (IS_WINDOW_MODE) return; 145 | 146 | if (tray) { 147 | console.log('Tray already exists, destroying old tray'); 148 | tray.destroy(); 149 | } 150 | 151 | console.log('Creating new tray'); 152 | tray = new Tray(path.join(__dirname, 'icon-idle.png')); 153 | tray.setToolTip('ClipShare'); 154 | 155 | const contextMenu = Menu.buildFromTemplate([ 156 | { 157 | label: 'Quit', 158 | click: () => { 159 | app.quit(); 160 | }, 161 | }, 162 | ]); 163 | 164 | tray.on('right-click', () => { 165 | tray.popUpContextMenu(contextMenu); 166 | }); 167 | 168 | tray.on('click', handleTrayClick); 169 | } 170 | 171 | app.on('ready', () => { 172 | console.log('App is ready'); 173 | createWindow(); 174 | if (!IS_WINDOW_MODE) { 175 | createTray(); 176 | } 177 | 178 | // Check for required environment variables 179 | if (!BUCKET_NAME || !process.env.ACCESS_KEY || !process.env.ACCESS_SECRET) { 180 | window.webContents.send('config-error'); 181 | console.error( 182 | 'Configuration error: BUCKET_NAME, ACCESS_KEY, or ACCESS_SECRET is not defined.' 183 | ); 184 | } 185 | 186 | // Periodic cleanup every 6 hours 187 | // setInterval(cleanupAndRecreate, 6 * 60 * 60 * 1000); 188 | }); 189 | 190 | function cleanupAndRecreate() { 191 | console.log('Performing periodic cleanup and recreation'); 192 | if (tray) { 193 | tray.destroy(); 194 | } 195 | createTray(); 196 | } 197 | 198 | ipcMain.handle('start-recording', (event) => { 199 | if (!IS_WINDOW_MODE) { 200 | setTrayIconRecording(true); 201 | } 202 | console.log('Recording started'); 203 | }); 204 | 205 | ipcMain.handle('stop-recording', (event) => { 206 | if (!IS_WINDOW_MODE) { 207 | setTrayIconRecording(false); 208 | } 209 | console.log('Recording stopped, tray icon updated to idle state.'); 210 | }); 211 | 212 | function setTrayIconRecording(isRecording) { 213 | const iconPath = isRecording ? 'icon-recording.png' : 'icon-idle.png'; 214 | tray.setImage(path.join(__dirname, iconPath)); 215 | } 216 | 217 | let cameraWindow = null; 218 | let cameraStream = null; 219 | 220 | function createCameraWindow() { 221 | cameraWindow = new BrowserWindow({ 222 | width: 200, 223 | height: 150, 224 | frame: false, 225 | alwaysOnTop: true, 226 | transparent: true, 227 | resizable: false, 228 | webPreferences: { 229 | nodeIntegration: true, 230 | contextIsolation: false, 231 | enableRemoteModule: true, 232 | webSecurity: false, 233 | permissions: ['camera', 'microphone'], 234 | }, 235 | }); 236 | 237 | cameraWindow.loadFile('camera.html'); 238 | 239 | const { screen } = require('electron'); 240 | const primaryDisplay = screen.getPrimaryDisplay(); 241 | const { width, height } = primaryDisplay.workAreaSize; 242 | 243 | cameraWindow.setPosition(20, height - 170); 244 | cameraWindow.on('closed', () => { 245 | cameraWindow = null; 246 | }); 247 | } 248 | 249 | ipcMain.handle('toggle-camera', async (event, enableCamera) => { 250 | console.log('Toggle camera called:', enableCamera); 251 | if (enableCamera) { 252 | if (!cameraWindow) { 253 | createCameraWindow(); 254 | } else { 255 | console.log('Camera window already exists'); 256 | cameraWindow.show(); 257 | } 258 | } else { 259 | if (cameraWindow) { 260 | console.log('Closing camera window'); 261 | cameraWindow.close(); 262 | cameraWindow = null; 263 | } 264 | } 265 | }); 266 | 267 | ipcMain.handle('get-camera-stream', async () => { 268 | if (cameraStream) { 269 | return cameraStream.id; 270 | } 271 | return null; 272 | }); 273 | 274 | ipcMain.handle('set-camera-stream', (event, streamId) => { 275 | cameraStream = { id: streamId }; 276 | }); 277 | 278 | ipcMain.handle('release-camera-stream', () => { 279 | if (cameraStream) { 280 | cameraStream = null; 281 | } 282 | }); 283 | 284 | ipcMain.handle('get-sources', async (event) => { 285 | console.log('Received get-sources request'); 286 | try { 287 | const sources = await desktopCapturer.getSources({ types: ['screen'] }); 288 | console.log('Sources:', sources); 289 | return sources; 290 | } catch (error) { 291 | console.error('Error getting sources:', error); 292 | throw error; 293 | } 294 | }); 295 | 296 | ipcMain.on('save-recording', async (event, buffer) => { 297 | const fileName = `${ulid()}.webm`; 298 | try { 299 | const params = { 300 | Bucket: BUCKET_NAME, 301 | Key: fileName, 302 | Body: buffer, 303 | ACL: ACL, 304 | ContentType: 'video/webm', 305 | }; 306 | 307 | const result = await s3.upload(params).promise(); 308 | let url; 309 | if (PRESIGN_URL) { 310 | url = s3.getSignedUrl('getObject', { 311 | Bucket: BUCKET_NAME, 312 | Key: fileName, 313 | Expires: PRESIGN_URL_EXPIRY, 314 | }); 315 | } else if (process.env.URL_PREFIX) { 316 | const prefix = process.env.URL_PREFIX.endsWith('/') 317 | ? process.env.URL_PREFIX 318 | : `${process.env.URL_PREFIX}/`; 319 | url = `${prefix}${fileName}`; 320 | } else { 321 | url = result.Location; 322 | } 323 | 324 | require('electron').shell.openExternal(url); 325 | event.reply('recording-saved', url); 326 | } catch (error) { 327 | console.error('Failed to upload video:', error); 328 | event.reply('recording-error', error.message); 329 | } 330 | }); 331 | 332 | // New IPC handler for checking camera permission 333 | ipcMain.handle('check-camera-permission', async () => { 334 | console.log('Checking camera permission'); 335 | if (process.platform !== 'darwin') { 336 | // On Windows and Linux, we can't check permissions this way 337 | console.log('Camera permission check not supported on this platform'); 338 | return 'unknown'; 339 | } 340 | 341 | try { 342 | const status = systemPreferences.getMediaAccessStatus('camera'); 343 | console.log('Camera permission status:', status); 344 | return status; 345 | } catch (error) { 346 | console.error('Error checking camera permission:', error); 347 | return 'error'; 348 | } 349 | }); 350 | 351 | app.whenReady().then(() => { 352 | createWindow(); 353 | createTray(); 354 | }); 355 | 356 | app.on('window-all-closed', () => { 357 | if (process.platform !== 'darwin') { 358 | app.quit(); 359 | } 360 | }); 361 | 362 | app.on('activate', () => { 363 | if (BrowserWindow.getAllWindows().length === 0) { 364 | createWindow(); 365 | } else if (IS_WINDOW_MODE) { 366 | window.show(); 367 | } 368 | }); 369 | 370 | app.on('before-quit', (event) => { 371 | console.log('App is about to quit.'); 372 | // Optionally, cancel the quit process if needed 373 | // event.preventDefault(); 374 | 375 | // Close all windows or perform any other cleanup 376 | if (window) { 377 | window.close(); 378 | } 379 | if (cameraWindow) { 380 | cameraWindow.close(); 381 | } 382 | app.isQuitting = true; 383 | }); 384 | 385 | // New IPC handler for quitting the app when .env variable is not set 386 | ipcMain.on('quit-app', () => { 387 | console.log('Quit app request received'); 388 | app.quit(); 389 | }); 390 | -------------------------------------------------------------------------------- /renderer.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron'); 2 | 3 | document.addEventListener('DOMContentLoaded', () => { 4 | console.log('DOM fully loaded'); 5 | const isWindowMode = process.env.MODE === 'window'; 6 | 7 | async function checkAndRequestCameraPermission() { 8 | try { 9 | const status = await ipcRenderer.invoke('check-camera-permission'); 10 | console.log('Camera permission status:', status); 11 | 12 | if (status === 'unknown' || status !== 'granted') { 13 | console.log('Requesting camera permission...'); 14 | const stream = await navigator.mediaDevices.getUserMedia({ 15 | video: true, 16 | }); 17 | stream.getTracks().forEach((track) => track.stop()); 18 | console.log('Camera permission granted'); 19 | return true; 20 | } else if (status === 'granted') { 21 | return true; 22 | } else { 23 | console.log('Camera permission not granted'); 24 | return false; 25 | } 26 | } catch (error) { 27 | console.error('Error requesting camera permission:', error); 28 | return false; 29 | } 30 | } 31 | function areEnvVariablesSet() { 32 | const requiredEnvVariables = [ 33 | 'ACCESS_KEY', 34 | 'ACCESS_SECRET', 35 | 'ENDPOINT', 36 | 'REGION', 37 | 'BUCKET_NAME', 38 | ]; 39 | 40 | for (const variable of requiredEnvVariables) { 41 | if (!process.env[variable]) { 42 | console.error(`Environment variable ${variable} is not set`); 43 | alert(`Please make sure all required environment variables are set.`); 44 | ipcRenderer.send('quit-app'); // Request the main process to close the app 45 | return false; 46 | } 47 | } 48 | return true; 49 | } 50 | 51 | // Call this function when your app starts 52 | // checkAndRequestCameraPermission(); 53 | 54 | const startBtn = document.getElementById('startBtn'); 55 | const stopBtn = document.getElementById('stopBtn'); 56 | const cancelBtn = document.getElementById('cancelBtn'); 57 | const audioCheckbox = document.getElementById('audioCheckbox'); 58 | const audioDeviceSelect = document.getElementById('audioDeviceSelect'); 59 | const cameraCheckbox = document.getElementById('cameraCheckbox'); 60 | 61 | // Add minimize and close buttons for window mode 62 | if (isWindowMode) { 63 | const controlsDiv = document.createElement('div'); 64 | controlsDiv.style.position = 'absolute'; 65 | controlsDiv.style.top = '5px'; 66 | controlsDiv.style.right = '5px'; 67 | 68 | const minimizeBtn = document.createElement('button'); 69 | minimizeBtn.textContent = '_'; 70 | minimizeBtn.onclick = () => ipcRenderer.send('minimize-window'); 71 | 72 | const closeBtn = document.createElement('button'); 73 | closeBtn.textContent = 'X'; 74 | closeBtn.onclick = () => ipcRenderer.send('close-window'); 75 | 76 | controlsDiv.appendChild(minimizeBtn); 77 | controlsDiv.appendChild(closeBtn); 78 | document.body.appendChild(controlsDiv); 79 | } 80 | 81 | cameraCheckbox.addEventListener('change', async () => { 82 | if (cameraCheckbox.checked) { 83 | const permissionGranted = await checkAndRequestCameraPermission(); 84 | if (permissionGranted) { 85 | ipcRenderer.invoke('toggle-camera', true); 86 | } else { 87 | cameraCheckbox.checked = false; 88 | alert('Camera permission is required to use this feature.'); 89 | } 90 | } else { 91 | ipcRenderer.invoke('toggle-camera', false); 92 | await releaseCameraStream(); 93 | } 94 | }); 95 | 96 | async function releaseCameraStream() { 97 | const streamId = await ipcRenderer.invoke('get-camera-stream'); 98 | if (streamId) { 99 | const stream = await navigator.mediaDevices.getUserMedia({ 100 | video: { mandatory: { chromeMediaSourceId: streamId } }, 101 | }); 102 | stream.getTracks().forEach((track) => track.stop()); 103 | await ipcRenderer.invoke('release-camera-stream'); 104 | } 105 | } 106 | 107 | console.log('Start button found:', !!startBtn); 108 | console.log('Stop button found:', !!stopBtn); 109 | console.log('Audio checkbox found:', !!audioCheckbox); 110 | console.log('Audio device select found:', !!audioDeviceSelect); 111 | 112 | let mediaRecorder; 113 | let recordedChunks = []; 114 | let isRecordingCancelled = false; 115 | 116 | const loadingOverlay = document.getElementById('loadingOverlay'); 117 | 118 | function showLoading() { 119 | loadingOverlay.style.display = 'flex'; 120 | } 121 | 122 | function hideLoading() { 123 | loadingOverlay.style.display = 'none'; 124 | } 125 | 126 | async function populateAudioDevices() { 127 | try { 128 | const devices = await navigator.mediaDevices.enumerateDevices(); 129 | console.log('All devices:', devices); 130 | const audioDevices = devices.filter( 131 | (device) => device.kind === 'audioinput' 132 | ); 133 | console.log('Audio devices:', audioDevices); 134 | audioDeviceSelect.innerHTML = ''; 135 | audioDevices.forEach((device) => { 136 | const option = document.createElement('option'); 137 | option.value = device.deviceId; 138 | option.text = 139 | device.label || `Microphone ${audioDeviceSelect.options.length + 1}`; 140 | audioDeviceSelect.appendChild(option); 141 | }); 142 | console.log('Audio devices populated:', audioDeviceSelect.options.length); 143 | } catch (error) { 144 | console.error('Error populating audio devices:', error); 145 | } 146 | } 147 | 148 | audioCheckbox.addEventListener('change', () => { 149 | console.log('Audio checkbox changed, checked:', audioCheckbox.checked); 150 | if (audioCheckbox.checked) { 151 | audioDeviceSelect.classList.add('visible'); 152 | populateAudioDevices(); // Populate devices when checked 153 | } else { 154 | audioDeviceSelect.classList.remove('visible'); 155 | } 156 | }); 157 | 158 | startBtn.addEventListener('click', () => { 159 | if (audioCheckbox.checked && !audioDeviceSelect.value) { 160 | alert('Please select an audio device.'); 161 | return; 162 | } 163 | if (!areEnvVariablesSet()) { 164 | console.log( 165 | 'Recording cannot start due to missing environment variables.' 166 | ); 167 | return; 168 | } 169 | ipcRenderer.invoke('start-recording'); // Notify main process that recording is starting 170 | startRecording( 171 | audioCheckbox.checked, 172 | audioDeviceSelect.value, 173 | cameraCheckbox.checked 174 | ); 175 | }); 176 | 177 | stopBtn.addEventListener('click', () => { 178 | console.log('Stop button clicked'); 179 | ipcRenderer.invoke('stop-recording'); 180 | stopRecording(); 181 | }); 182 | 183 | cancelBtn.addEventListener('click', () => { 184 | console.log('Stop button clicked'); 185 | ipcRenderer.invoke('stop-recording'); 186 | cancelRecording(); 187 | }); 188 | 189 | async function startRecording(recordAudio, audioDeviceId, enableCamera) { 190 | try { 191 | const sources = await ipcRenderer.invoke('get-sources'); 192 | const source = sources[0]; 193 | 194 | const constraints = { 195 | video: { 196 | mandatory: { 197 | chromeMediaSource: 'desktop', 198 | chromeMediaSourceId: source.id, 199 | }, 200 | }, 201 | }; 202 | 203 | let videoStream = await navigator.mediaDevices.getUserMedia(constraints); 204 | 205 | if (recordAudio && audioDeviceId) { 206 | try { 207 | const audioStream = await navigator.mediaDevices.getUserMedia({ 208 | audio: { 209 | deviceId: { exact: audioDeviceId }, 210 | noiseSuppression: true, 211 | echoCancellation: true, 212 | autoGainControl: true, 213 | }, 214 | }); 215 | videoStream.addTrack(audioStream.getAudioTracks()[0]); 216 | } catch (audioError) { 217 | console.error('Error capturing audio:', audioError); 218 | alert( 219 | `Error capturing audio: ${audioError.message}. Continuing with video only.` 220 | ); 221 | } 222 | } 223 | 224 | const options = { mimeType: 'video/webm; codecs=vp9' }; 225 | mediaRecorder = new MediaRecorder(videoStream, options); 226 | 227 | mediaRecorder.ondataavailable = handleDataAvailable; 228 | mediaRecorder.onstop = handleStop; 229 | 230 | mediaRecorder.start(); 231 | startBtn.disabled = true; 232 | stopBtn.disabled = false; 233 | cancelBtn.disabled = false; 234 | } catch (e) { 235 | console.error('Error starting recording:', e); 236 | alert(`Error starting recording: ${e.message}`); 237 | } 238 | } 239 | 240 | startBtn.addEventListener('click', () => { 241 | if (audioCheckbox.checked && !audioDeviceSelect.value) { 242 | alert('Please select an audio device.'); 243 | return; 244 | } 245 | ipcRenderer.invoke('start-recording'); // Notify main process that recording is starting 246 | startRecording( 247 | audioCheckbox.checked, 248 | audioDeviceSelect.value, 249 | cameraCheckbox.checked 250 | ); 251 | }); 252 | 253 | function stopRecording() { 254 | console.log('Stopping recording'); 255 | if (mediaRecorder && mediaRecorder.state !== 'inactive') { 256 | mediaRecorder.stop(); 257 | console.log('MediaRecorder stopped'); 258 | startBtn.disabled = false; 259 | stopBtn.disabled = true; 260 | console.log('Buttons updated'); 261 | } else { 262 | console.log('MediaRecorder not active, cannot stop'); 263 | } 264 | } 265 | 266 | function cancelRecording() { 267 | if (mediaRecorder && mediaRecorder.state !== 'inactive') { 268 | isRecordingCancelled = true; 269 | mediaRecorder.stop(); 270 | recordedChunks = []; 271 | startBtn.disabled = false; 272 | stopBtn.disabled = true; 273 | cancelBtn.disabled = true; 274 | alert('Recording cancelled. No data will be uploaded.'); 275 | } 276 | } 277 | 278 | function handleDataAvailable(e) { 279 | console.log('Data available'); 280 | recordedChunks.push(e.data); 281 | } 282 | 283 | async function handleStop() { 284 | if (isRecordingCancelled) { 285 | console.log( 286 | 'Recording was cancelled. No data will be processed or uploaded.' 287 | ); 288 | isRecordingCancelled = false; 289 | return; 290 | } 291 | 292 | console.log('Recording stopped, processing data'); 293 | const blob = new Blob(recordedChunks, { type: 'video/webm; codecs=vp9' }); 294 | console.log('Blob created, size:', blob.size); 295 | const buffer = Buffer.from(await blob.arrayBuffer()); 296 | console.log('Buffer created, length:', buffer.length); 297 | showLoading(); 298 | ipcRenderer.send('save-recording', buffer); 299 | console.log('Save recording message sent to main process'); 300 | recordedChunks = []; 301 | } 302 | 303 | ipcRenderer.on('recording-saved', (event, url) => { 304 | console.log('Recording saved and uploaded successfully'); 305 | console.log('Video URL:', url); 306 | hideLoading(); 307 | // alert(`Recording saved and uploaded successfully. Video URL: ${url}`); 308 | }); 309 | 310 | ipcRenderer.on('recording-error', (event, error) => { 311 | console.error('Error saving or uploading recording:', error); 312 | alert(`Error saving or uploading recording: ${error}`); 313 | hideLoading(); 314 | }); 315 | 316 | // Initialize audio devices 317 | populateAudioDevices(); 318 | 319 | console.log('Renderer script loaded'); 320 | }); 321 | --------------------------------------------------------------------------------