diff --git a/.github/workflows/pull-request-build.yml b/.github/workflows/pull-request-build.yml index fb65f155b2..3fb9a8bd6a 100644 --- a/.github/workflows/pull-request-build.yml +++ b/.github/workflows/pull-request-build.yml @@ -522,7 +522,7 @@ jobs: - name: Build Ubuntu Package if: ${{ matrix.build-target == 'linux' }} - run: yarn electron-builder --publish never --linux snap deb + run: yarn electron-builder --publish never --linux snap deb appimage - name: Find Snap File id: find_snap @@ -586,6 +586,7 @@ jobs: --endpoint-url=https://s3.us-east-1.wasabisys.com \ --exclude "*" \ --include "rocketchat-*.snap" \ + --include "rocketchat-*.AppImage" \ --include "rocketchat-*.deb" env: AWS_ACCESS_KEY_ID: ${{ secrets.WASABI_ACCESS_KEY_ID }} @@ -601,7 +602,7 @@ jobs: const path = require('path'); const distDir = path.join(process.cwd(), 'dist'); const files = fs.readdirSync(distDir); - const patterns = [/rocketchat-.*\.dmg$/, /rocketchat-.*\.pkg$/, /rocketchat-.*\.exe$/, /rocketchat-.*\.snap$/, /rocketchat-.*\.deb$/]; + const patterns = [/rocketchat-.*\.dmg$/, /rocketchat-.*\.pkg$/, /rocketchat-.*\.exe$/, /rocketchat-.*\.snap$/, /rocketchat-.*\.deb$/, /rocketchat-.*\.appimage$/i]; let artifactUrls = ''; for (const file of files) { if (patterns.some(pattern => pattern.test(file))) { @@ -616,9 +617,7 @@ jobs: uses: marocchino/sticky-pull-request-comment@v2 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + header: ${{ runner.os }}-installer message: | ### ${{ runner.os }} installer download ${{ steps.get-artifact-urls.outputs.artifact_urls }} - header: '### ${{ runner.os }} installer download' - recreate: true - append: false diff --git a/docs/linux-display-server.md b/docs/linux-display-server.md new file mode 100644 index 0000000000..2508c9b809 --- /dev/null +++ b/docs/linux-display-server.md @@ -0,0 +1,136 @@ +# Linux Display Server Configuration + +Rocket.Chat Desktop uses native Wayland by default when available, with automatic GPU crash recovery. + +## Default Behavior + +| Package Type | Display Server | Notes | +|--------------|---------------|-------| +| **deb/rpm** | Native (Wayland/X11) | Uses system default | +| **Snap** | Native (Wayland/X11) | Uses system default | +| **AppImage** | Native (Wayland/X11) | Uses system default | + +## Automatic GPU Crash Recovery + +Rocket.Chat Desktop includes automatic GPU crash detection and recovery: + +1. **Crash Detection**: If the GPU process crashes during startup (e.g., due to incompatible drivers), the app detects this automatically. + +2. **Automatic Fallback**: After detecting repeated crashes, the app relaunches with GPU acceleration disabled. + +3. **Recovery**: Once GPU is disabled, the app will continue to run with software rendering. + +## Wayland Support + +Rocket.Chat Desktop includes native Wayland support with: +- PipeWire screen capture for video calls +- Native OS screen picker integration +- Hardware acceleration + +### Requirements for Wayland +- `xdg-desktop-portal` (recommended, auto-suggested by deb/rpm packages) +- `xdg-desktop-portal-gtk` or desktop-specific portal backend +- PipeWire for screen sharing +- Modern GPU drivers + +## Known Issues + +### Virtual Machines +VMs with paravirtual graphics (QXL, VirtualBox, VMware) may have issues with GPU acceleration. + +**Symptoms:** +```text +ERROR:viz_main_impl.cc:189] Exiting GPU process due to errors during initialization +``` + +**Automatic Recovery:** The app automatically detects this crash and disables GPU acceleration. You should see the app restart and work normally after the crashes. + +**Manual Solution:** If auto-recovery doesn't work, disable GPU: +```bash +rocketchat-desktop --disable-gpu +``` + +### Screen Sharing Not Working on Wayland + +If screen sharing doesn't work on Wayland: + +1. Install portal dependencies: +```bash +# Ubuntu/Debian +sudo apt install xdg-desktop-portal xdg-desktop-portal-gtk pipewire + +# Fedora +sudo dnf install xdg-desktop-portal xdg-desktop-portal-gtk pipewire + +# Arch +sudo pacman -S xdg-desktop-portal xdg-desktop-portal-gtk pipewire +``` + +2. Restart your session or reboot + +3. Verify PipeWire is running: +```bash +systemctl --user status pipewire +``` + +## Manual Override + +You can force a specific display server: + +```bash +# Force X11 +rocketchat-desktop --ozone-platform=x11 + +# Force Wayland +rocketchat-desktop --ozone-platform=wayland + +# Disable GPU acceleration +rocketchat-desktop --disable-gpu +``` + +## Troubleshooting + +### Check Your Display Server +```bash +echo $XDG_SESSION_TYPE +# Output: "wayland" or "x11" +``` + +### Check GPU Support +```bash +# Check graphics card +lspci | grep -i vga + +# Check VA-API support (hardware video decode) +vainfo +``` + +### Enable Verbose Logging + +These are standard Chromium flags that enable detailed logging output: + +```bash +rocketchat-desktop --enable-logging --v=1 +``` + +### Disable GPU Acceleration (last resort) +```bash +rocketchat-desktop --disable-gpu +``` + +## Best Practices + +- **Real Hardware + Wayland**: Use default for best experience +- **VM**: The app will auto-detect and disable GPU after crashes +- **X11 Session**: Works automatically +- **Snap Package**: Uses native display server + +## Reporting Issues + +When reporting display server issues, include: + +1. Display server type: `echo $XDG_SESSION_TYPE` +2. Graphics card: `lspci | grep -i vga` +3. Distribution: `cat /etc/os-release` +4. Package type: snap/deb/rpm/AppImage +5. Full error output with `--enable-logging --v=1` diff --git a/electron-builder.json b/electron-builder.json index 606a6a009e..0033c19a5b 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -133,10 +133,18 @@ "deb": { "fpm": [ "--after-install=build/linux/postinst.sh" + ], + "recommends": [ + "xdg-desktop-portal", + "xdg-desktop-portal-gtk" ] }, "rpm": { - "fpm": ["--rpm-rpmbuild-define=_build_id_links none"] + "fpm": [ + "--rpm-rpmbuild-define=_build_id_links none", + "--rpm-tag=Recommends: xdg-desktop-portal", + "--rpm-tag=Recommends: xdg-desktop-portal-gtk" + ] }, "snap": { "plugs": [ @@ -144,6 +152,7 @@ "desktop-legacy", "home", "x11", + "wayland", "unity7", "browser-support", "network", @@ -154,9 +163,9 @@ "audio-playback", "audio-record", "screen-inhibit-control", - "upower-observe" - ], - "executableArgs": ["--ozone-platform=x11"] + "upower-observe", + "pipewire" + ] }, "afterSign": "./build/notarize.js", "generateUpdatesFilesForAllChannels": true, diff --git a/package.json b/package.json index 29a67a0276..47f1011d1e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "productName": "Rocket.Chat", "name": "rocketchat", "description": "Official OSX, Windows, and Linux Desktop Clients for Rocket.Chat", - "version": "4.10.1", + "version": "4.10.2", "author": "Rocket.Chat Support ", "copyright": "© 2016-2025, Rocket.Chat", "homepage": "https://rocket.chat", diff --git a/rollup.config.mjs b/rollup.config.mjs index 8a1d2b12c2..681dbac57f 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -34,7 +34,14 @@ const run = () => { proc ? 'Restarting main process...' : 'Starting main process...' ); - proc = spawn(electron, ['.'], { stdio: 'inherit' }); + const electronArgs = ['.']; + + // Linux-specific flags for development + if (process.platform === 'linux') { + electronArgs.push('--no-sandbox'); + } + + proc = spawn(electron, electronArgs, { stdio: 'inherit' }); proc.on('close', () => { proc = null; diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000000..59bdb8b225 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,139 @@ +# Testing Scripts + +This directory contains automation scripts for testing Rocket.Chat Desktop on Linux. + +## Scripts + +### `install-volta.sh` + +Installs Volta (JavaScript toolchain manager) if it's not already installed. Volta provides node.js and npm, which are required for building the project. + +#### Usage + +```bash +# Interactive mode (prompts before installing) +./scripts/install-volta.sh + +# Non-interactive mode (installs automatically) +./scripts/install-volta.sh --non-interactive +``` + +#### What It Does + +1. **Checks if Volta is installed** + - Looks for `volta` command in PATH + - Checks `$VOLTA_HOME` environment variable + - Checks common installation location (`~/.volta/bin/volta`) + +2. **Installs Volta if missing** + - Downloads and runs the official Volta installer + - Adds Volta to PATH for current session + - Adds Volta to `~/.bashrc` for future sessions + +3. **Verifies installation** + - Checks that `volta` command is available + - Displays Volta version + +#### Features + +- Can be sourced by other scripts to use its functions +- Non-interactive mode for CI/automation +- Automatically adds Volta to shell profile +- Safe to run multiple times (skips if already installed) + +#### Requirements + +- `curl` (for downloading Volta installer) +- Internet connection + +### `linux-test-deb.sh` + +Builds, installs, and runs the Rocket.Chat Desktop .deb package for testing purposes. + +#### Usage + +```bash +./scripts/linux-test-deb.sh [OPTIONS] +``` + +#### Options + +- `--skip-build` - Skip building the .deb package (useful if you've already built it) +- `--skip-install` - Skip installing the .deb package (useful for testing installation separately) +- `--skip-run` - Skip running the installed app (useful if you just want to install) +- `--help`, `-h` - Show help message + +#### Examples + +Build, install, and run: +```bash +./scripts/linux-test-deb.sh +``` + +Skip build if .deb already exists: +```bash +./scripts/linux-test-deb.sh --skip-build +``` + +Only build and install, don't run: +```bash +./scripts/linux-test-deb.sh --skip-run +``` + +#### What It Does + +1. **Builds the .deb package** + - Runs `yarn build-linux` to build the app and create Linux packages + - Outputs to `dist/` directory + +2. **Finds the .deb file** + - Searches for `dist/rocketchat-*-linux-*.deb` + - Uses the first matching file found + +3. **Installs the .deb package** + - Uninstalls any previous version if present + - Installs the new package using `dpkg -i` + - Automatically fixes missing dependencies with `apt-get install -f` + +4. **Runs the installed app** + - Launches Rocket.Chat Desktop from `/opt/Rocket.Chat/rocketchat-desktop` + - Runs in background and displays the process ID + +#### Requirements + +- Linux operating system +- `node.js` and `yarn` (or Volta - the script will install it automatically if needed) +- `dpkg` and `apt-get` (standard on Debian/Ubuntu systems) +- `curl` (for installing Volta if needed) +- Sudo access (for installation, if not running as root) + +#### Automatic Dependency Installation + +The script automatically installs missing dependencies: +- **Volta** (if node.js/yarn not found) - provides node.js and npm +- **binutils** (if `ar` command not found) - required for building .deb packages +- **Package dependencies** - automatically resolved during .deb installation + +#### Output + +The script provides color-coded output: +- 🔵 Blue: Informational messages +- 🟢 Green: Success messages +- 🟡 Yellow: Warnings +- 🔴 Red: Errors + +#### Error Handling + +The script will exit with a non-zero status code if any step fails. Common issues: + +- **Build fails**: Check that all dependencies are installed (`yarn install`) +- **Install fails**: Ensure you have sudo access and the .deb file is valid +- **App not found**: The installation path may differ; check `/opt/Rocket.Chat/` or use `which rocketchat-desktop` + +#### Notes + +- The script automatically handles uninstalling previous versions +- Missing dependencies are automatically resolved during installation +- The app runs in the background; use `kill ` to stop it +- This script is intended for development/testing purposes only + diff --git a/scripts/install-volta.sh b/scripts/install-volta.sh new file mode 100755 index 0000000000..2549409938 --- /dev/null +++ b/scripts/install-volta.sh @@ -0,0 +1,150 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored messages +info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if Volta is already installed +check_volta_installed() { + if command -v volta &> /dev/null; then + return 0 + fi + + # Also check if VOLTA_HOME is set and points to a valid installation + if [ -n "$VOLTA_HOME" ] && [ -f "$VOLTA_HOME/bin/volta" ]; then + return 0 + fi + + # Check common installation location + if [ -f "$HOME/.volta/bin/volta" ]; then + return 0 + fi + + return 1 +} + +# Install Volta +install_volta() { + info "Installing Volta..." + + # Check if curl is available + if ! command -v curl &> /dev/null; then + error "curl is required to install Volta" + error "Please install curl first: sudo apt-get install curl" + return 1 + fi + + # Install Volta using the official installer + if ! curl https://get.volta.sh | bash; then + error "Failed to install Volta" + return 1 + fi + + # Source Volta in current shell + export VOLTA_HOME="$HOME/.volta" + export PATH="$VOLTA_HOME/bin:$PATH" + + # Verify installation + if command -v volta &> /dev/null; then + success "Volta installed successfully!" + volta --version + return 0 + else + error "Volta installation completed but 'volta' command not found" + error "You may need to restart your shell or run: source ~/.bashrc" + return 1 + fi +} + +# Main function +main() { + # Check if running in non-interactive mode + NON_INTERACTIVE=false + if [ "$1" = "--non-interactive" ] || [ -n "$CI" ]; then + NON_INTERACTIVE=true + fi + + # Check if Volta is already installed + if check_volta_installed; then + VOLTA_VERSION=$(volta --version 2>/dev/null || echo "installed") + success "Volta is already installed ($VOLTA_VERSION)" + + # Ensure Volta is in PATH + if [ -d "$HOME/.volta/bin" ] && [[ "$PATH" != *".volta"* ]]; then + export VOLTA_HOME="$HOME/.volta" + export PATH="$VOLTA_HOME/bin:$PATH" + info "Added Volta to PATH" + fi + + return 0 + fi + + # Volta not installed, ask user or install automatically + if [ "$NON_INTERACTIVE" = false ]; then + echo "" + warning "Volta is not installed." + echo "Volta is a JavaScript toolchain manager that provides node.js and npm." + echo "" + read -p "Do you want to install Volta now? (y/n) " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + info "Skipping Volta installation" + return 1 + fi + else + info "Volta not found, installing automatically..." + fi + + # Install Volta + if install_volta; then + # Ensure it's in PATH for subsequent commands + export VOLTA_HOME="$HOME/.volta" + export PATH="$VOLTA_HOME/bin:$PATH" + + # Add to shell profile if not already there + if [ -f "$HOME/.bashrc" ] && ! grep -q "VOLTA_HOME" "$HOME/.bashrc"; then + info "Adding Volta to ~/.bashrc for future sessions..." + echo '' >> "$HOME/.bashrc" + echo '# Volta' >> "$HOME/.bashrc" + echo 'export VOLTA_HOME="$HOME/.volta"' >> "$HOME/.bashrc" + echo 'export PATH="$VOLTA_HOME/bin:$PATH"' >> "$HOME/.bashrc" + fi + + return 0 + else + return 1 + fi +} + +# If script is executed directly (not sourced), run main +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" + exit $? +fi + +# If sourced, export functions for use in other scripts +export -f check_volta_installed +export -f install_volta + diff --git a/scripts/linux-test-appimage.sh b/scripts/linux-test-appimage.sh new file mode 100755 index 0000000000..def5bf126c --- /dev/null +++ b/scripts/linux-test-appimage.sh @@ -0,0 +1,231 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$PROJECT_DIR" + +echo -e "${BLUE}=== Rocket.Chat Linux AppImage Testing Script ===${NC}\n" + +# Check if running on Linux +if [[ "$OSTYPE" != "linux-gnu"* ]]; then + echo -e "${RED}Error: This script is designed for Linux only${NC}" + exit 1 +fi + +# Function to print colored messages +info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Parse command line arguments +SKIP_BUILD=false +SKIP_INSTALL=false +SKIP_RUN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --skip-build) + SKIP_BUILD=true + shift + ;; + --skip-install) + SKIP_INSTALL=true + shift + ;; + --skip-run) + SKIP_RUN=true + shift + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --skip-build Skip building the AppImage" + echo " --skip-install Skip making AppImage executable" + echo " --skip-run Skip running the AppImage" + echo " --help, -h Show this help message" + exit 0 + ;; + *) + error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Step 1: Build the AppImage +if [ "$SKIP_BUILD" = false ]; then + info "Building Linux AppImage..." + + # Ensure Volta is installed (provides node.js) + if ! command -v node &> /dev/null && ! command -v yarn &> /dev/null; then + info "Checking for Volta (JavaScript toolchain manager)..." + if [ -f "$SCRIPT_DIR/install-volta.sh" ]; then + # Source the install script to get its functions, or run it + bash "$SCRIPT_DIR/install-volta.sh" --non-interactive || { + warning "Volta installation skipped or failed" + warning "You may need node.js/yarn installed manually" + } + # Ensure Volta is in PATH + if [ -d "$HOME/.volta/bin" ]; then + export PATH="$HOME/.volta/bin:$PATH" + fi + fi + fi + + # Find yarn command + YARN_CMD="" + if command -v yarn &> /dev/null; then + YARN_CMD="yarn" + elif [ -f "$PROJECT_DIR/.yarn/releases/yarn-4.6.0.cjs" ]; then + # Use local yarn binary if available + if command -v node &> /dev/null; then + YARN_CMD="node $PROJECT_DIR/.yarn/releases/yarn-4.6.0.cjs" + else + error "Neither 'yarn' nor 'node' is available. Please install yarn or node." + exit 1 + fi + else + error "yarn is not installed and local yarn binary not found." + error "Please install yarn or ensure .yarn/releases/yarn-*.cjs exists." + exit 1 + fi + + info "Using yarn: $YARN_CMD" + + # Build AppImage + info "Building AppImage..." + if ! $YARN_CMD build; then + error "Build failed!" + exit 1 + fi + if ! $YARN_CMD electron-builder --publish never --linux AppImage; then + error "Build failed!" + exit 1 + fi + + success "Build completed!" +else + info "Skipping build (--skip-build flag set)" +fi + +# Step 2: Find the AppImage file +info "Looking for AppImage in dist/ directory..." + +APPIMAGE_FILE=$(find dist -name "rocketchat-*-linux-*.AppImage" -type f | head -n 1) + +if [ -z "$APPIMAGE_FILE" ]; then + error "No AppImage file found in dist/ directory" + error "Expected pattern: dist/rocketchat-*-linux-*.AppImage" + exit 1 +fi + +success "Found AppImage: $APPIMAGE_FILE" + +# Step 3: Make the AppImage executable +if [ "$SKIP_INSTALL" = false ]; then + info "Making AppImage executable..." + + chmod +x "$APPIMAGE_FILE" + + success "AppImage is now executable!" +else + info "Skipping making executable (--skip-install flag set)" +fi + +# Step 4: Run the AppImage +if [ "$SKIP_RUN" = false ]; then + info "Launching Rocket.Chat from AppImage..." + + # Kill any existing Rocket.Chat instances to ensure clean start + EXISTING_PIDS=$(pgrep -f "rocketchat.*AppImage" 2>/dev/null || true) + if [ -n "$EXISTING_PIDS" ]; then + warning "Found existing Rocket.Chat processes, stopping them..." + echo "$EXISTING_PIDS" | xargs kill -9 2>/dev/null || true + sleep 1 + fi + + # Clear saved window state to ensure window appears + ROCKET_CONFIG_DIR="$HOME/.config/Rocket.Chat" + if [ -f "$ROCKET_CONFIG_DIR/rootWindowState.json" ]; then + info "Clearing saved window state to ensure window appears..." + rm -f "$ROCKET_CONFIG_DIR/rootWindowState.json" + fi + + success "Starting Rocket.Chat from: $APPIMAGE_FILE" + + # Ensure DISPLAY is set for GUI apps + if [ -z "$DISPLAY" ]; then + export DISPLAY=:0 + info "Setting DISPLAY=$DISPLAY" + fi + + # Launch the AppImage directly (app handles X11 enforcement internally) + nohup "$APPIMAGE_FILE" > /tmp/rocketchat-appimage.log 2>&1 & + + # Get the PID + sleep 1 + APP_PID=$(pgrep -f "rocketchat.*AppImage" | head -1) + + if [ -z "$APP_PID" ]; then + error "Failed to start Rocket.Chat" + if [ -f /tmp/rocketchat-appimage.log ]; then + error "Last 10 lines of log:" + tail -10 /tmp/rocketchat-appimage.log | sed 's/^/ /' + fi + exit 1 + fi + + # Wait for window to appear + info "Waiting for window to appear..." + sleep 5 + + # Give the app a moment to start and show the window + sleep 3 + + # Check if the process is still running + if ! kill -0 "$APP_PID" 2>/dev/null; then + warning "App process exited quickly. Check logs: /tmp/rocketchat-appimage.log" + if [ -f /tmp/rocketchat-appimage.log ]; then + error "Last 10 lines of log:" + tail -10 /tmp/rocketchat-appimage.log | sed 's/^/ /' + fi + warning "Testing completed with warnings - app exited early" + else + echo "" + success "Rocket.Chat started! (PID: $APP_PID)" + info "The app window should be visible now." + info "Logs are available at: /tmp/rocketchat-appimage.log" + info "To stop it, run: kill $APP_PID" + echo "" + success "All steps completed successfully!" + fi +else + info "Skipping run (--skip-run flag set)" + success "All steps completed successfully!" +fi + diff --git a/scripts/linux-test-deb.sh b/scripts/linux-test-deb.sh new file mode 100755 index 0000000000..c01ed3c081 --- /dev/null +++ b/scripts/linux-test-deb.sh @@ -0,0 +1,313 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$PROJECT_DIR" + +echo -e "${BLUE}=== Rocket.Chat Linux .deb Testing Script ===${NC}\n" + +# Check if running on Linux +if [[ "$OSTYPE" != "linux-gnu"* ]]; then + echo -e "${RED}Error: This script is designed for Linux only${NC}" + exit 1 +fi + +# Check if we need sudo for install operations +# dpkg operations always require root, so use sudo unless we're already root +NEED_SUDO=false +if [ "$EUID" -ne 0 ]; then + NEED_SUDO=true +fi + +# Function to print colored messages +info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Parse command line arguments +SKIP_BUILD=false +SKIP_INSTALL=false +SKIP_RUN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --skip-build) + SKIP_BUILD=true + shift + ;; + --skip-install) + SKIP_INSTALL=true + shift + ;; + --skip-run) + SKIP_RUN=true + shift + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --skip-build Skip building the .deb package" + echo " --skip-install Skip installing the .deb package" + echo " --skip-run Skip running the installed app" + echo " --help, -h Show this help message" + exit 0 + ;; + *) + error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Step 1: Build the .deb package +if [ "$SKIP_BUILD" = false ]; then + info "Building Linux .deb package..." + + # Ensure Volta is installed (provides node.js) + if ! command -v node &> /dev/null && ! command -v yarn &> /dev/null; then + info "Checking for Volta (JavaScript toolchain manager)..." + if [ -f "$SCRIPT_DIR/install-volta.sh" ]; then + # Source the install script to get its functions, or run it + bash "$SCRIPT_DIR/install-volta.sh" --non-interactive || { + warning "Volta installation skipped or failed" + warning "You may need node.js/yarn installed manually" + } + # Ensure Volta is in PATH + if [ -d "$HOME/.volta/bin" ]; then + export PATH="$HOME/.volta/bin:$PATH" + fi + fi + fi + + # Check for required build dependencies and install if missing + MISSING_DEPS=() + if ! command -v ar &> /dev/null; then + MISSING_DEPS+=("binutils") + fi + + if [ ${#MISSING_DEPS[@]} -gt 0 ]; then + warning "Missing required build dependencies: ${MISSING_DEPS[*]}" + info "Attempting to install missing dependencies..." + + if [ "$NEED_SUDO" = true ]; then + if ! sudo apt-get update || ! sudo apt-get install -y "${MISSING_DEPS[@]}"; then + error "Failed to install dependencies: ${MISSING_DEPS[*]}" + error "Please install them manually with: sudo apt-get install ${MISSING_DEPS[*]}" + exit 1 + fi + else + if ! apt-get update || ! apt-get install -y "${MISSING_DEPS[@]}"; then + error "Failed to install dependencies: ${MISSING_DEPS[*]}" + error "Please install them manually with: sudo apt-get install ${MISSING_DEPS[*]}" + exit 1 + fi + fi + + success "Dependencies installed successfully!" + fi + + # Find yarn command + YARN_CMD="" + if command -v yarn &> /dev/null; then + YARN_CMD="yarn" + elif [ -f "$PROJECT_DIR/.yarn/releases/yarn-4.6.0.cjs" ]; then + # Use local yarn binary if available + if command -v node &> /dev/null; then + YARN_CMD="node $PROJECT_DIR/.yarn/releases/yarn-4.6.0.cjs" + else + error "Neither 'yarn' nor 'node' is available. Please install yarn or node." + exit 1 + fi + else + error "yarn is not installed and local yarn binary not found." + error "Please install yarn or ensure .yarn/releases/yarn-*.cjs exists." + exit 1 + fi + + info "Using yarn: $YARN_CMD" + + # Build only .deb package to avoid issues with other Linux targets + info "Building .deb package only..." + if ! $YARN_CMD build; then + error "Build failed!" + exit 1 + fi + if ! $YARN_CMD electron-builder --publish never --linux deb; then + error "Build failed!" + exit 1 + fi + + success "Build completed!" +else + info "Skipping build (--skip-build flag set)" +fi + +# Step 2: Find the .deb file +info "Looking for .deb package in dist/ directory..." + +DEB_FILE=$(find dist -name "rocketchat-*-linux-*.deb" -type f | head -n 1) + +if [ -z "$DEB_FILE" ]; then + error "No .deb file found in dist/ directory" + error "Expected pattern: dist/rocketchat-*-linux-*.deb" + exit 1 +fi + +success "Found .deb package: $DEB_FILE" + +# Step 3: Install the .deb package +if [ "$SKIP_INSTALL" = false ]; then + info "Installing .deb package..." + + # Check if Rocket.Chat is already installed + if dpkg -l | grep -q "rocketchat-desktop"; then + warning "Rocket.Chat is already installed. Uninstalling previous version..." + + if [ "$NEED_SUDO" = true ]; then + sudo dpkg -r rocketchat-desktop 2>/dev/null || true + sudo apt-get purge -y rocketchat-desktop 2>/dev/null || true + else + dpkg -r rocketchat-desktop 2>/dev/null || true + apt-get purge -y rocketchat-desktop 2>/dev/null || true + fi + fi + + # Install the new package + if [ "$NEED_SUDO" = true ]; then + set +e + sudo dpkg -i "$DEB_FILE" + dpkg_rc=$? + set -e + if [ $dpkg_rc -ne 0 ]; then + warning "Installation had dependency issues, attempting to fix..." + sudo apt-get install -f -y + fi + else + set +e + dpkg -i "$DEB_FILE" + dpkg_rc=$? + set -e + if [ $dpkg_rc -ne 0 ]; then + warning "Installation had dependency issues, attempting to fix..." + apt-get install -f -y + fi + fi + + success "Package installed successfully!" +else + info "Skipping install (--skip-install flag set)" +fi + +# Step 4: Run the installed app +if [ "$SKIP_RUN" = false ]; then + info "Launching Rocket.Chat..." + + # Kill any existing Rocket.Chat instances to ensure clean start + EXISTING_PIDS=$(pgrep -f "rocketchat-desktop" 2>/dev/null || true) + if [ -n "$EXISTING_PIDS" ]; then + warning "Found existing Rocket.Chat processes, stopping them..." + echo "$EXISTING_PIDS" | xargs kill -9 2>/dev/null || true + sleep 1 + fi + + # Clear saved window state to ensure window appears + # The app might have saved a "hidden" state + ROCKET_CONFIG_DIR="$HOME/.config/Rocket.Chat" + if [ -f "$ROCKET_CONFIG_DIR/rootWindowState.json" ]; then + info "Clearing saved window state to ensure window appears..." + rm -f "$ROCKET_CONFIG_DIR/rootWindowState.json" + fi + + # Standard Electron app install path + APP_PATH="/opt/Rocket.Chat/rocketchat-desktop" + + if [ ! -f "$APP_PATH" ]; then + error "App not found at expected path: $APP_PATH" + error "Trying alternative: rocketchat-desktop command" + + if command -v rocketchat-desktop &> /dev/null; then + APP_PATH="rocketchat-desktop" + else + error "Could not find installed Rocket.Chat executable" + exit 1 + fi + fi + + success "Starting Rocket.Chat from: $APP_PATH" + + # Ensure DISPLAY is set for GUI apps + if [ -z "$DISPLAY" ]; then + export DISPLAY=:0 + info "Setting DISPLAY=$DISPLAY" + fi + + # Launch the app directly (app handles X11 enforcement internally) + nohup "$APP_PATH" > /tmp/rocketchat-desktop.log 2>&1 & + + # Get the PID + sleep 1 + APP_PID=$(pgrep -f "rocketchat-desktop" | head -1) + + if [ -z "$APP_PID" ]; then + error "Failed to start Rocket.Chat" + if [ -f /tmp/rocketchat-desktop.log ]; then + error "Last 10 lines of log:" + tail -10 /tmp/rocketchat-desktop.log | sed 's/^/ /' + fi + exit 1 + fi + + # Wait for window to appear + info "Waiting for window to appear..." + sleep 5 + + # Give the app a moment to start and show the window + sleep 3 + + # Check if the process is still running + if ! kill -0 "$APP_PID" 2>/dev/null; then + warning "App process exited quickly. Check logs: /tmp/rocketchat-desktop.log" + if [ -f /tmp/rocketchat-desktop.log ]; then + error "Last 10 lines of log:" + tail -10 /tmp/rocketchat-desktop.log | sed 's/^/ /' + fi + warning "Testing completed with warnings - app exited early" + else + echo "" + success "Rocket.Chat started! (PID: $APP_PID)" + info "The app window should be visible now." + info "Logs are available at: /tmp/rocketchat-desktop.log" + info "To stop it, run: kill $APP_PID" + echo "" + success "All steps completed successfully!" + fi +else + info "Skipping run (--skip-run flag set)" + success "All steps completed successfully!" +fi + diff --git a/src/app/main/app.ts b/src/app/main/app.ts index f86ae3be14..1e71c1f764 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -1,3 +1,5 @@ +import { spawn } from 'child_process'; + import { app, session, BrowserWindow } from 'electron'; import { rimraf } from 'rimraf'; @@ -56,6 +58,24 @@ export const getPlatformName = (): string => { }; export const relaunchApp = (...args: string[]): void => { + // For AppImage, use spawn to relaunch because app.relaunch() doesn't work reliably + if (process.env.APPIMAGE) { + console.log('Relaunching AppImage:', { + appImage: process.env.APPIMAGE, + args, + }); + + // Spawn the AppImage as a detached process + spawn(process.env.APPIMAGE, args, { + detached: true, + stdio: 'ignore', + }).unref(); + + app.exit(); + return; + } + + // For non-AppImage, use the standard relaunch method const command = process.argv.slice(1, app.isPackaged ? 1 : 2); app.relaunch({ args: [...command, ...args] }); app.exit(); @@ -65,7 +85,7 @@ export const performElectronStartup = (): void => { app.setAsDefaultProtocolClient(electronBuilderJsonInformation.protocol); app.setAppUserModelId(electronBuilderJsonInformation.appId); - app.commandLine.appendSwitch('--autoplay-policy', 'no-user-gesture-required'); + app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); const disabledChromiumFeatures = [ 'HardwareMediaKeyHandling', @@ -76,10 +96,6 @@ export const performElectronStartup = (): void => { app.commandLine.appendSwitch('disable-accelerated-video-decode'); } - if (process.platform === 'linux') { - app.commandLine.appendSwitch('ozone-platform', 'x11'); - } - const args = process.argv.slice(app.isPackaged ? 1 : 2); if (args.includes('--reset-app-data')) { @@ -110,9 +126,9 @@ export const performElectronStartup = (): void => { ) { console.log('Disabling Hardware acceleration'); app.disableHardwareAcceleration(); - app.commandLine.appendSwitch('--disable-2d-canvas-image-chromium'); - app.commandLine.appendSwitch('--disable-accelerated-2d-canvas'); - app.commandLine.appendSwitch('--disable-gpu'); + app.commandLine.appendSwitch('disable-2d-canvas-image-chromium'); + app.commandLine.appendSwitch('disable-accelerated-2d-canvas'); + app.commandLine.appendSwitch('disable-gpu'); } if (process.platform === 'win32') { @@ -142,6 +158,11 @@ export const performElectronStartup = (): void => { 'disable-features', disabledChromiumFeatures.join(',') ); + + // Enable PipeWire screen capture for Linux (Wayland support) + if (process.platform === 'linux') { + app.commandLine.appendSwitch('enable-features', 'WebRTCPipeWireCapturer'); + } }; export const initializeScreenCaptureFallbackState = (): void => { @@ -151,6 +172,56 @@ export const initializeScreenCaptureFallbackState = (): void => { }); }; +export const markMainWindowStable = (): void => { + // No-op: kept for API compatibility +}; + +export const setupGpuCrashHandler = (): void => { + if (process.platform !== 'linux') { + return; + } + + const args = process.argv.slice(app.isPackaged ? 1 : 2); + if (args.includes('--disable-gpu')) { + return; + } + + // Handle GPU process crashes (runtime failures) + app.on('child-process-gone', (_event, details) => { + if (details.type !== 'GPU') { + return; + } + + console.log('GPU process crashed, disabling GPU and relaunching with X11'); + const userArgs = process.argv.slice(app.isPackaged ? 1 : 2); + relaunchApp('--disable-gpu', '--ozone-platform=x11', ...userArgs); + }); + + // Proactive GPU detection: check GPU status once info is available + app.once('gpu-info-update', () => { + const gpuFeatures = app.getGPUFeatureStatus(); + const { gpu_compositing: gpuCompositing, webgl } = gpuFeatures; + + // Check if key GPU features are disabled/unavailable (includes _software variants) + const isGpuBroken = (status: string | undefined): boolean => + !status || + status.startsWith('disabled') || + status.startsWith('unavailable'); + + if (isGpuBroken(gpuCompositing) || isGpuBroken(webgl)) { + console.log( + 'GPU features unavailable, disabling GPU and relaunching with X11', + JSON.stringify(gpuFeatures) + ); + const userArgs = process.argv.slice(app.isPackaged ? 1 : 2); + relaunchApp('--disable-gpu', '--ozone-platform=x11', ...userArgs); + return; + } + + console.log('GPU features status:', JSON.stringify(gpuFeatures)); + }); +}; + export const setupApp = (): void => { app.addListener('activate', async () => { try { diff --git a/src/main.ts b/src/main.ts index 1819013726..86520ddc8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,8 @@ import { performElectronStartup, setupApp, initializeScreenCaptureFallbackState, + setupGpuCrashHandler, + markMainWindowStable, } from './app/main/app'; import { mergePersistableValues, @@ -52,6 +54,9 @@ const start = async (): Promise => { performElectronStartup(); + // Set up GPU crash handler BEFORE whenReady to catch early GPU failures + setupGpuCrashHandler(); + await app.whenReady(); createMainReduxStore(); @@ -78,6 +83,9 @@ const start = async (): Promise => { attachGuestWebContentsEvents(); await showRootWindow(); + // Mark main window as stable - GPU crashes after this won't trigger fallback + markMainWindowStable(); + // React DevTools is currently incompatible with Electron 10 // if (process.env.NODE_ENV === 'development') { // installDevTools(); diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 3f2834f7f8..7d79db1440 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -26,17 +26,30 @@ import { debounce } from '../ui/main/debounce'; import { handleMediaPermissionRequest } from '../ui/main/mediaPermissions'; import { isInsideSomeScreen, getRootWindow } from '../ui/main/rootWindow'; import { openExternal } from '../utils/browserLauncher'; +import type { + DisplayMediaCallback, + ScreenPickerProvider, +} from './screenPicker/types'; const DESKTOP_CAPTURER_STALE_THRESHOLD = 3000; const SOURCE_VALIDATION_CACHE_TTL = 30000; const DESTRUCTION_CHECK_INTERVAL = 50; const DEVTOOLS_TIMEOUT = 2000; const WEBVIEW_CHECK_INTERVAL = 100; +const SCREEN_SHARING_REQUEST_TIMEOUT = 60000; // 60 seconds timeout let videoCallWindow: BrowserWindow | null = null; let isVideoCallWindowDestroying = false; let pendingVideoCallUrl: string | null = null; +// Screen sharing request tracking +let activeScreenSharingListener: + | ((event: Event, sourceId: string | null) => void) + | null = null; +let activeScreenSharingRequestId: string | null = null; +let screenSharingTimeout: NodeJS.Timeout | null = null; +let isScreenSharingRequestPending = false; + // Helper function to log URL changes const setPendingVideoCallUrl = (url: string, reason: string) => { const previous = pendingVideoCallUrl; @@ -209,6 +222,9 @@ const cleanupVideoCallWindow = () => { ); } + // Clean up screen sharing listener before removing window listeners + cleanupScreenSharingListener(); + videoCallWindow.removeAllListeners(); // Use setImmediate to ensure this happens after current event loop @@ -246,55 +262,235 @@ const cleanupVideoCallWindow = () => { }, 10); }; -const setupWebviewHandlers = (webContents: WebContents) => { - const handleDidAttachWebview = ( - _event: Event, - webviewWebContents: WebContents - ): void => { - webviewWebContents.session.setDisplayMediaRequestHandler((_request, cb) => { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - videoCallWindow.webContents.send( - 'video-call-window/open-screen-picker' +const cleanupScreenSharingListener = (): void => { + if (activeScreenSharingListener) { + ipcMain.removeListener( + 'video-call-window/screen-sharing-source-responded', + activeScreenSharingListener + ); + activeScreenSharingListener = null; + } + + if (screenSharingTimeout) { + clearTimeout(screenSharingTimeout); + screenSharingTimeout = null; + } + + activeScreenSharingRequestId = null; + isScreenSharingRequestPending = false; +}; + +const removeScreenSharingListenerOnly = (): void => { + if (activeScreenSharingListener) { + ipcMain.removeListener( + 'video-call-window/screen-sharing-source-responded', + activeScreenSharingListener + ); + activeScreenSharingListener = null; + } + + if (screenSharingTimeout) { + clearTimeout(screenSharingTimeout); + screenSharingTimeout = null; + } +}; + +const markScreenSharingComplete = (): void => { + activeScreenSharingRequestId = null; + isScreenSharingRequestPending = false; +}; + +// Internal picker handler function - encapsulates the internal picker logic +const createInternalPickerHandler = (): (( + callback: DisplayMediaCallback +) => void) => { + return (cb: DisplayMediaCallback) => { + // Prevent concurrent requests + if (isScreenSharingRequestPending) { + console.warn( + 'Screen sharing request already pending, ignoring concurrent request' + ); + cb({ video: false } as any); + return; + } + + if (!videoCallWindow || videoCallWindow.isDestroyed()) { + console.warn( + 'Screen sharing request rejected - video call window not available' + ); + cb({ video: false } as any); + return; + } + + // Clean up any existing listener + cleanupScreenSharingListener(); + + // Generate unique request ID + const requestId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + activeScreenSharingRequestId = requestId; + isScreenSharingRequestPending = true; + + // Flag to prevent double-invocation of callback + let callbackInvoked = false; + + // Create the listener function + const listener = async (_event: Event, sourceId: string | null) => { + // Ignore responses for different requests + if (activeScreenSharingRequestId !== requestId) { + console.warn( + 'Screen sharing response received for different request, ignoring' ); + return; + } - ipcMain.once( - 'video-call-window/screen-sharing-source-responded', - async (_event, sourceId) => { - if (!sourceId) { - cb({ video: false } as any); - return; - } + // Guard against double-invocation (race with timeout) + if (callbackInvoked) { + console.warn( + 'Screen sharing callback already invoked, ignoring duplicate' + ); + return; + } + callbackInvoked = true; - try { - const sources = await desktopCapturer.getSources({ - types: ['window', 'screen'], - }); + // Remove listener and clear timeout + removeScreenSharingListenerOnly(); - const selectedSource = sources.find((s) => s.id === sourceId); + if (!sourceId) { + cb({ video: false } as any); + markScreenSharingComplete(); + return; + } - if (!selectedSource) { - console.warn( - 'Selected screen sharing source no longer available:', - sourceId - ); - cb({ video: false } as any); - return; - } + try { + const sources = await desktopCapturer.getSources({ + types: ['window', 'screen'], + }); - cb({ video: selectedSource }); - } catch (error) { - console.error('Error validating screen sharing source:', error); - cb({ video: false } as any); - } - } + const selectedSource = sources.find((s) => s.id === sourceId); + + if (!selectedSource) { + console.warn( + 'Selected screen sharing source no longer available:', + sourceId + ); + cb({ video: false } as any); + markScreenSharingComplete(); + return; + } + + cb({ video: selectedSource }); + markScreenSharingComplete(); + } catch (error) { + console.error('Error validating screen sharing source:', error); + cb({ video: false } as any); + markScreenSharingComplete(); + } + }; + + // Store listener for cleanup + activeScreenSharingListener = listener; + + // Set timeout to prevent orphaned listeners + screenSharingTimeout = setTimeout(() => { + if (activeScreenSharingRequestId !== requestId) { + console.warn( + 'Screen sharing timeout fired for different request, ignoring' ); + return; } - }); + + if (callbackInvoked) { + console.warn( + 'Screen sharing callback already invoked by listener, ignoring timeout' + ); + return; + } + callbackInvoked = true; + + console.warn('Screen sharing request timed out, cleaning up listener'); + + removeScreenSharingListenerOnly(); + cb({ video: false } as any); + markScreenSharingComplete(); + }, SCREEN_SHARING_REQUEST_TIMEOUT); + + // Register listener + ipcMain.once('video-call-window/screen-sharing-source-responded', listener); + + // Send request to open picker + videoCallWindow.webContents.send('video-call-window/open-screen-picker'); }; +}; - webContents.removeAllListeners('did-attach-webview'); +const setupWebviewHandlers = (webContents: WebContents) => { + // Track attached webviews that need handler setup + const pendingWebviews: WebContents[] = []; + let screenPickerReady = false; + let provider: ScreenPickerProvider | null = null; + + // Function to set up display media handler for a webview + const setupDisplayMediaHandler = (webviewWebContents: WebContents): void => { + if (!provider) return; + const currentProvider = provider; // Capture for closure + try { + // useSystemPicker is an experimental macOS 15+ option; not available on other platforms. + // We set it to false unconditionally and use the callback handler on all platforms to + // enable custom source selection (including PipeWire on Wayland via XDG portal). + webviewWebContents.session.setDisplayMediaRequestHandler( + (_request, cb) => { + try { + currentProvider.handleDisplayMediaRequest(cb); + } catch (error) { + console.error('Error in screen picker handler:', error); + cb({ video: false } as any); + } + }, + { useSystemPicker: false } // Always false - portal handled via callback on Linux + ); + } catch (error) { + console.error('Error setting up display media request handler:', error); + } + }; + + // Register listener SYNCHRONOUSLY before async work + const handleDidAttachWebview = ( + _event: Event, + webviewWebContents: WebContents + ): void => { + if (screenPickerReady && provider) { + setupDisplayMediaHandler(webviewWebContents); + } else { + pendingWebviews.push(webviewWebContents); + } + }; + webContents.removeAllListeners('did-attach-webview'); webContents.on('did-attach-webview', handleDidAttachWebview); + + // Load screen picker module asynchronously + import('./screenPicker') + .then((screenPickerModule) => { + const { createScreenPicker, InternalPickerProvider } = screenPickerModule; + provider = createScreenPicker(); + + if (provider instanceof InternalPickerProvider) { + provider.setHandleRequestHandler(createInternalPickerHandler()); + } + + screenPickerReady = true; + + // Set up handlers for any webviews that attached while loading + for (const webviewWebContents of pendingWebviews) { + if (!webviewWebContents.isDestroyed()) { + setupDisplayMediaHandler(webviewWebContents); + } + } + pendingWebviews.length = 0; + }) + .catch((error) => { + console.error('Error loading screen picker module:', error); + // Don't prevent webview from loading if screen picker setup fails + }); }; export const startVideoCallWindowHandler = (): void => { @@ -508,6 +704,9 @@ export const startVideoCallWindowHandler = (): void => { videoCallWindow.on('closed', () => { console.log('Video call window closed - destroying completely'); + // Clean up screen sharing listener + cleanupScreenSharingListener(); + // Use setTimeout to ensure cleanup happens after any potential app lifecycle events // This prevents crashes during first launch when timing is critical setTimeout(() => { @@ -533,6 +732,9 @@ export const startVideoCallWindowHandler = (): void => { 'Video call window close initiated - preventing JS execution' ); + // Clean up screen sharing listener + cleanupScreenSharingListener(); + try { if (videoCallWindow && !videoCallWindow.isDestroyed()) { videoCallWindow.webContents.session.setPermissionRequestHandler( @@ -674,6 +876,7 @@ export const startVideoCallWindowHandler = (): void => { const { webContents } = videoCallWindow; + // Setup webview handlers (listener registered synchronously, module loads async) setupWebviewHandlers(webContents); // Set the pending URL after window is created to prevent race condition with cleanup diff --git a/src/videoCallWindow/screenPicker/createScreenPicker.ts b/src/videoCallWindow/screenPicker/createScreenPicker.ts new file mode 100644 index 0000000000..313673f9ad --- /dev/null +++ b/src/videoCallWindow/screenPicker/createScreenPicker.ts @@ -0,0 +1,59 @@ +import { InternalPickerProvider } from './providers/InternalPickerProvider'; +import { PortalPickerProvider } from './providers/PortalPickerProvider'; +import type { ScreenPickerProvider, ScreenPickerType } from './types'; + +function detectPickerType(): ScreenPickerType { + if (process.platform === 'linux') { + const sessionType = process.env.XDG_SESSION_TYPE; + const desktop = process.env.XDG_CURRENT_DESKTOP || ''; + + const isWayland = sessionType === 'wayland'; + const hasPortal = + /GNOME|KDE|XFCE|Cinnamon|MATE|Pantheon|Budgie|Unity/i.test(desktop); + + if (isWayland || hasPortal) { + return 'portal'; + } + } + + // Future: Add macOS native picker detection here + // if (process.platform === 'darwin' && hasMacOSNativePicker()) { + // return 'native-macos'; + // } + + return 'internal'; +} + +let cachedProvider: ScreenPickerProvider | null = null; + +export function createScreenPicker(): ScreenPickerProvider { + if (cachedProvider) return cachedProvider; + + const type = detectPickerType(); + + switch (type) { + case 'portal': + cachedProvider = new PortalPickerProvider(); + break; + case 'internal': + default: + cachedProvider = new InternalPickerProvider(); + } + + console.log(`Screen picker: using ${cachedProvider.type} provider`); + return cachedProvider; +} + +export function getScreenPicker(): ScreenPickerProvider { + if (!cachedProvider) { + throw new Error( + 'Screen picker not initialized. Call createScreenPicker() first.' + ); + } + return cachedProvider; +} + +export function resetScreenPicker(): void { + cachedProvider?.cleanup(); + cachedProvider = null; +} diff --git a/src/videoCallWindow/screenPicker/index.ts b/src/videoCallWindow/screenPicker/index.ts new file mode 100644 index 0000000000..399d7a2ea6 --- /dev/null +++ b/src/videoCallWindow/screenPicker/index.ts @@ -0,0 +1,12 @@ +export { + createScreenPicker, + getScreenPicker, + resetScreenPicker, +} from './createScreenPicker'; +export type { + ScreenPickerProvider, + ScreenPickerType, + DisplayMediaCallback, +} from './types'; +export { InternalPickerProvider } from './providers/InternalPickerProvider'; +export { PortalPickerProvider } from './providers/PortalPickerProvider'; diff --git a/src/videoCallWindow/screenPicker/providers/InternalPickerProvider.ts b/src/videoCallWindow/screenPicker/providers/InternalPickerProvider.ts new file mode 100644 index 0000000000..5116b49f3d --- /dev/null +++ b/src/videoCallWindow/screenPicker/providers/InternalPickerProvider.ts @@ -0,0 +1,75 @@ +import type { DisplayMediaCallback, ScreenPickerProvider } from '../types'; + +/** + * Internal picker provider that uses the React UI and desktopCapturer cache. + * This provider works in both main and renderer processes: + * - handleDisplayMediaRequest: called from main process, uses ipc.ts state + * - initialize: called from renderer process, preloads UI and cache + * + * Note: This provider needs access to state in ipc.ts. The factory will + * pass a handler function that encapsulates the internal picker logic. + */ +export class InternalPickerProvider implements ScreenPickerProvider { + readonly type = 'internal' as const; + + readonly requiresInternalUI = true; + + readonly requiresCacheWarming = true; + + private isInitialized = false; + + private handleRequestFn: ((callback: DisplayMediaCallback) => void) | null = + null; + + private initializeFn: (() => Promise) | null = null; + + /** + * Set the handler function from main process (ipc.ts) + * This encapsulates all the internal picker logic with access to ipc.ts state + */ + setHandleRequestHandler( + handler: (callback: DisplayMediaCallback) => void + ): void { + this.handleRequestFn = handler; + } + + /** + * Set the initialize function from renderer process (video-call-window.ts) + * This encapsulates the UI preload and cache warming logic + */ + setInitializeHandler(handler: () => Promise): void { + this.initializeFn = handler; + } + + handleDisplayMediaRequest(callback: DisplayMediaCallback): void { + if (this.handleRequestFn) { + this.handleRequestFn(callback); + } else { + console.error( + 'InternalPickerProvider: handleRequest handler not set. This should be set by ipc.ts' + ); + callback({ video: false }); + } + } + + async initialize(): Promise { + if (this.isInitialized) return; + + console.log('Screen picker [internal]: initializing cache and UI'); + + if (this.initializeFn) { + await this.initializeFn(); + } else { + console.warn( + 'InternalPickerProvider: initialize handler not set. Call setInitializeHandler() first.' + ); + } + + this.isInitialized = true; + } + + cleanup(): void { + // Clear caches, reset state + this.isInitialized = false; + } +} diff --git a/src/videoCallWindow/screenPicker/providers/PortalPickerProvider.ts b/src/videoCallWindow/screenPicker/providers/PortalPickerProvider.ts new file mode 100644 index 0000000000..1cfd36f047 --- /dev/null +++ b/src/videoCallWindow/screenPicker/providers/PortalPickerProvider.ts @@ -0,0 +1,50 @@ +import { desktopCapturer } from 'electron'; + +import type { DisplayMediaCallback, ScreenPickerProvider } from '../types'; + +export class PortalPickerProvider implements ScreenPickerProvider { + readonly type = 'portal' as const; + + readonly requiresInternalUI = false; + + readonly requiresCacheWarming = false; + + handleDisplayMediaRequest(callback: DisplayMediaCallback): void { + // On Linux/Wayland, calling getSources() triggers the XDG portal picker. + // The portal typically returns exactly one source on selection or an empty array + // on cancellation; we defensively check for > 0 and use only the first source. + console.log( + 'Screen picker [portal]: triggering XDG portal picker via getSources()' + ); + + desktopCapturer + .getSources({ types: ['screen', 'window'] }) + .then((sources) => { + if (sources.length > 0) { + // User selected a source via portal picker + callback({ video: sources[0] }); + } else { + // User cancelled or no source available + console.warn('Screen picker [portal]: No source selected by user'); + callback({ video: false } as any); + } + }) + .catch((error) => { + console.error( + 'Screen picker [portal]: Failed to get source from XDG portal:', + error + ); + callback({ video: false } as any); + }); + } + + async initialize(): Promise { + // Portal picker requires no initialization + console.log('Screen picker [portal]: initialized (no-op)'); + } + + cleanup(): void { + // Portal picker requires no cleanup + console.log('Screen picker [portal]: cleanup (no-op)'); + } +} diff --git a/src/videoCallWindow/screenPicker/types.ts b/src/videoCallWindow/screenPicker/types.ts new file mode 100644 index 0000000000..56673c5833 --- /dev/null +++ b/src/videoCallWindow/screenPicker/types.ts @@ -0,0 +1,34 @@ +export type ScreenPickerType = 'internal' | 'portal'; + +// DisplayMediaCallback matches Electron's setDisplayMediaRequestHandler callback +// We use 'any' to match the existing code pattern that uses 'as any' casts for flexibility +export type DisplayMediaCallback = (streams: any) => void; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ScreenPickerProvider { + /** Identifier for this picker type */ + readonly type: ScreenPickerType; + + /** Whether this picker needs the internal React UI mounted */ + readonly requiresInternalUI: boolean; + + /** Whether this picker needs desktopCapturer cache warming */ + readonly requiresCacheWarming: boolean; + + /** + * Handle a display media request from the webview. + * Called by setDisplayMediaRequestHandler in main process. + */ + handleDisplayMediaRequest(callback: DisplayMediaCallback): void; + + /** + * Initialize the picker when video call window is created. + * Called in renderer process after webview loads. + */ + initialize(): Promise; + + /** + * Cleanup when video call window is destroyed. + */ + cleanup(): void; +} diff --git a/src/videoCallWindow/video-call-window.ts b/src/videoCallWindow/video-call-window.ts index 0eb1debd71..4704e2a782 100644 --- a/src/videoCallWindow/video-call-window.ts +++ b/src/videoCallWindow/video-call-window.ts @@ -7,6 +7,7 @@ import { interpolation, fallbackLng } from '../i18n/common'; import resources from '../i18n/resources'; import { invokeWithRetry } from '../ipc/renderer'; import type { IRetryOptions } from '../ipc/renderer'; +import { createScreenPicker, InternalPickerProvider } from './screenPicker'; import type { ScreenSharePickerModuleType } from './screenSharePickerMount'; const MAX_INIT_ATTEMPTS = 10; @@ -467,11 +468,23 @@ const setupWebviewEventHandlers = (webview: HTMLElement): void => { ); }); - preloadScreenSharePicker(); + // Initialize the screen picker (provider handles its own needs) + const provider = createScreenPicker(); + + // Set initialize handler for internal picker + if (provider instanceof InternalPickerProvider) { + provider.setInitializeHandler(async () => { + await preloadScreenSharePicker(); + ipcRenderer + .invoke('video-call-window/prewarm-capturer-cache') + .catch(() => { + // Silent failure - cache warming is optional optimization + }); + }); + } - // Pre-warm desktop capturer cache in background to avoid empty state on first open - ipcRenderer.invoke('video-call-window/prewarm-capturer-cache').catch(() => { - // Silent failure - cache warming is optional optimization + provider.initialize().catch((error) => { + console.error('Screen picker initialization failed:', error); }); }; @@ -650,7 +663,7 @@ const createWebview = (url: string): void => { setupWebviewEventHandlers(webview); }; -const preloadScreenSharePicker = async (): Promise => { +export const preloadScreenSharePicker = async (): Promise => { if (screenPickerModule) { return; }