From 9689386e5677624bc0afa898799295da1e89b34c Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 16 Dec 2025 14:44:02 -0300 Subject: [PATCH 01/33] feat: Enhance Linux support for screen sharing and dependencies - Added `xdg-desktop-portal` and `xdg-desktop-portal-gtk` as recommended dependencies in the Electron builder configuration for improved screen sharing functionality on Linux. - Removed the ozone platform switch for `x11` in the Electron startup process and enabled PipeWire screen capture support for Wayland environments. - Updated the display media request handler to utilize system picker for better user experience during screen sharing. --- electron-builder.json | 16 ++++++--- src/app/main/app.ts | 9 ++--- src/videoCallWindow/ipc.ts | 69 ++++++++++++++++++++------------------ 3 files changed, 53 insertions(+), 41 deletions(-) diff --git a/electron-builder.json b/electron-builder.json index 606a6a009e..db9147b132 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": [ @@ -154,9 +162,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/src/app/main/app.ts b/src/app/main/app.ts index f86ae3be14..4d17b582d3 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -76,10 +76,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')) { @@ -142,6 +138,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 => { diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 3f2834f7f8..1cfbb8fd9c 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -251,45 +251,48 @@ const setupWebviewHandlers = (webContents: WebContents) => { _event: Event, webviewWebContents: WebContents ): void => { - webviewWebContents.session.setDisplayMediaRequestHandler((_request, cb) => { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - videoCallWindow.webContents.send( - 'video-call-window/open-screen-picker' - ); - - ipcMain.once( - 'video-call-window/screen-sharing-source-responded', - async (_event, sourceId) => { - if (!sourceId) { - cb({ video: false } as any); - return; - } - - try { - const sources = await desktopCapturer.getSources({ - types: ['window', 'screen'], - }); - - const selectedSource = sources.find((s) => s.id === sourceId); + webviewWebContents.session.setDisplayMediaRequestHandler( + (_request, cb) => { + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + videoCallWindow.webContents.send( + 'video-call-window/open-screen-picker' + ); - if (!selectedSource) { - console.warn( - 'Selected screen sharing source no longer available:', - sourceId - ); + ipcMain.once( + 'video-call-window/screen-sharing-source-responded', + async (_event, sourceId) => { + if (!sourceId) { cb({ video: false } as any); return; } - cb({ video: selectedSource }); - } catch (error) { - console.error('Error validating screen sharing source:', error); - cb({ video: false } as any); + try { + const sources = await desktopCapturer.getSources({ + types: ['window', 'screen'], + }); + + 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); + return; + } + + cb({ video: selectedSource }); + } catch (error) { + console.error('Error validating screen sharing source:', error); + cb({ video: false } as any); + } } - } - ); - } - }); + ); + } + }, + { useSystemPicker: true } + ); }; webContents.removeAllListeners('did-attach-webview'); From 74d4c520f09699b9de2e1a757d7dc0c6d667f24f Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 16 Dec 2025 14:45:11 -0300 Subject: [PATCH 02/33] chore: Bump version to 4.10.2 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 5fd2177821a3d022d127f5d27e65f1019269e562 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 16 Dec 2025 14:58:34 -0300 Subject: [PATCH 03/33] feat(videoCall): Implement screen sharing request handling and cleanup logic - Added a timeout mechanism for screen sharing requests to prevent orphaned listeners. - Introduced cleanup functions for screen sharing listeners and request states to enhance resource management. - Improved IPC handling for screen sharing source responses, ensuring proper request tracking and error handling. - Enhanced user experience by preventing concurrent screen sharing requests and managing listener cleanup effectively. --- src/videoCallWindow/ipc.ts | 157 +++++++++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 32 deletions(-) diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 1cfbb8fd9c..90a2f0c49e 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -32,11 +32,20 @@ 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 +218,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,6 +258,24 @@ const cleanupVideoCallWindow = () => { }, 10); }; +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 setupWebviewHandlers = (webContents: WebContents) => { const handleDidAttachWebview = ( _event: Event, @@ -253,43 +283,100 @@ const setupWebviewHandlers = (webContents: WebContents) => { ): void => { webviewWebContents.session.setDisplayMediaRequestHandler( (_request, cb) => { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - videoCallWindow.webContents.send( - 'video-call-window/open-screen-picker' + // Prevent concurrent requests + if (isScreenSharingRequestPending) { + console.warn( + 'Screen sharing request already pending, ignoring concurrent request' ); + cb({ video: false } as any); + return; + } - ipcMain.once( - 'video-call-window/screen-sharing-source-responded', - async (_event, sourceId) => { - if (!sourceId) { - 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; + } - try { - const sources = await desktopCapturer.getSources({ - types: ['window', 'screen'], - }); - - 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); - return; - } - - cb({ video: selectedSource }); - } catch (error) { - console.error('Error validating screen sharing source:', error); - cb({ video: false } as any); - } + // Clean up any existing listener + cleanupScreenSharingListener(); + + // Generate unique request ID + const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + activeScreenSharingRequestId = requestId; + isScreenSharingRequestPending = true; + + // 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; + } + + // Clean up timeout + if (screenSharingTimeout) { + clearTimeout(screenSharingTimeout); + screenSharingTimeout = null; + } + + // Remove listener + cleanupScreenSharingListener(); + + if (!sourceId) { + cb({ video: false } as any); + return; + } + + try { + const sources = await desktopCapturer.getSources({ + types: ['window', 'screen'], + }); + + 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); + return; } + + cb({ video: selectedSource }); + } catch (error) { + console.error('Error validating screen sharing source:', error); + cb({ video: false } as any); + } + }; + + // Store listener for cleanup + activeScreenSharingListener = listener; + + // Set timeout to prevent orphaned listeners + screenSharingTimeout = setTimeout(() => { + console.warn( + 'Screen sharing request timed out, cleaning up listener' ); - } + cleanupScreenSharingListener(); + cb({ video: false } as any); + }, 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' + ); }, { useSystemPicker: true } ); @@ -511,6 +598,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(() => { @@ -536,6 +626,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( From 43fca4fc90f093a994671429cb647a2f2cbdce77 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 16 Dec 2025 16:09:42 -0300 Subject: [PATCH 04/33] fix(videoCall): Improve screen sharing timeout handling and listener cleanup - Added validation to ensure the screen sharing timeout is only processed for the current request, preventing double-invocation. - Enhanced cleanup logic to clear timeout references and invoke callbacks appropriately, improving resource management and user experience during screen sharing sessions. --- src/videoCallWindow/ipc.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 90a2f0c49e..473a8176ee 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -360,10 +360,26 @@ const setupWebviewHandlers = (webContents: WebContents) => { // Set timeout to prevent orphaned listeners screenSharingTimeout = setTimeout(() => { + // Validate that this timeout is for the current request + // This prevents double-invocation if the listener already handled it + if (activeScreenSharingRequestId !== requestId) { + console.warn( + 'Screen sharing timeout fired for different request, ignoring' + ); + return; + } + console.warn( 'Screen sharing request timed out, cleaning up listener' ); + + // Clear timeout reference to prevent racing callbacks + screenSharingTimeout = null; + + // Clean up listener (mirrors listener path) cleanupScreenSharingListener(); + + // Invoke callback once (mirrors listener path) cb({ video: false } as any); }, SCREEN_SHARING_REQUEST_TIMEOUT); From bf197555f81a96807e7cdcde0a5ad7e318d69093 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 16 Dec 2025 17:03:48 -0300 Subject: [PATCH 05/33] fix(videoCall): Refactor screen sharing listener cleanup and request completion handling - Introduced `removeScreenSharingListenerOnly` to streamline listener removal and timeout clearing without altering the pending request state. - Added `markScreenSharingComplete` to reset request states, allowing new screen sharing requests after completion. - Enhanced existing cleanup logic to improve resource management and prevent concurrent screen sharing requests. --- src/videoCallWindow/ipc.ts | 49 ++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 473a8176ee..8bf6f52b2f 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -276,6 +276,26 @@ const cleanupScreenSharingListener = (): void => { 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; +}; + const setupWebviewHandlers = (webContents: WebContents) => { const handleDidAttachWebview = ( _event: Event, @@ -318,17 +338,14 @@ const setupWebviewHandlers = (webContents: WebContents) => { return; } - // Clean up timeout - if (screenSharingTimeout) { - clearTimeout(screenSharingTimeout); - screenSharingTimeout = null; - } - - // Remove listener - cleanupScreenSharingListener(); + // Remove listener and clear timeout (prevent re-firing) + // BUT keep isScreenSharingRequestPending=true to block concurrent requests + removeScreenSharingListenerOnly(); if (!sourceId) { cb({ video: false } as any); + // Now mark complete to allow new requests + markScreenSharingComplete(); return; } @@ -345,13 +362,19 @@ const setupWebviewHandlers = (webContents: WebContents) => { sourceId ); cb({ video: false } as any); + // Now mark complete to allow new requests + markScreenSharingComplete(); return; } cb({ video: selectedSource }); + // Now mark complete to allow new requests + markScreenSharingComplete(); } catch (error) { console.error('Error validating screen sharing source:', error); cb({ video: false } as any); + // Now mark complete to allow new requests + markScreenSharingComplete(); } }; @@ -373,14 +396,14 @@ const setupWebviewHandlers = (webContents: WebContents) => { 'Screen sharing request timed out, cleaning up listener' ); - // Clear timeout reference to prevent racing callbacks - screenSharingTimeout = null; - - // Clean up listener (mirrors listener path) - cleanupScreenSharingListener(); + // Remove listener only (keep pending flag to block concurrent requests) + removeScreenSharingListenerOnly(); // Invoke callback once (mirrors listener path) cb({ video: false } as any); + + // Now mark complete to allow new requests + markScreenSharingComplete(); }, SCREEN_SHARING_REQUEST_TIMEOUT); // Register listener From 15369734f846a655d4d6836f521fdc6d81bca1d9 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 17 Dec 2025 19:58:47 -0300 Subject: [PATCH 06/33] feat(gpuFallback): Implement GPU crash detection and fallback mode for Linux - Added a GPU crash handler to automatically switch to X11 mode if the GPU process crashes during startup. - Introduced a persistent fallback mode setting to remember the user's choice across sessions. - Enhanced the settings UI to allow users to control the display server mode, including options for auto-detect, force X11, or disable GPU. - Updated the application to handle GPU fallback mode changes dynamically, improving user experience and stability on Linux systems. --- docs/linux-display-server.md | 146 ++++++++++++++++ electron-builder.json | 3 +- src/app/PersistableValues.ts | 14 +- src/app/main/app.ts | 160 +++++++++++++++++- src/app/selectors.ts | 1 + src/i18n/en.i18n.json | 9 + src/main.ts | 8 + src/store/readSetting.ts | 29 ++++ src/store/rootReducer.ts | 2 + src/ui/actions.ts | 4 + src/ui/components/SettingsView/GeneralTab.tsx | 2 + .../SettingsView/features/GpuFallbackMode.tsx | 78 +++++++++ src/ui/reducers/gpuFallbackMode.ts | 30 ++++ 13 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 docs/linux-display-server.md create mode 100644 src/ui/components/SettingsView/features/GpuFallbackMode.tsx create mode 100644 src/ui/reducers/gpuFallbackMode.ts diff --git a/docs/linux-display-server.md b/docs/linux-display-server.md new file mode 100644 index 0000000000..0d51fc27eb --- /dev/null +++ b/docs/linux-display-server.md @@ -0,0 +1,146 @@ +# Linux Display Server Configuration + +Rocket.Chat Desktop automatically detects and uses the best display server (Wayland or X11) for your system. If the app detects GPU issues during startup, it automatically switches to a compatible mode. + +## Default Behavior + +| Package Type | Display Server | Notes | +|--------------|---------------|-------| +| **deb/rpm** | Auto-detect | Uses Wayland if available, falls back to X11 | +| **Snap** | X11 (forced) | Due to sandbox limitations | +| **AppImage** | Auto-detect | Uses Wayland if available, falls back to X11 | + +## Automatic GPU Crash Recovery + +Starting with version 4.10.2, 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 crashes, the app saves a fallback mode and relaunches with X11 forced. + +3. **Persistent Setting**: The fallback mode is remembered across restarts, preventing repeated crashes. + +4. **User Control**: You can view and reset the fallback mode in Settings > General > Display Server Mode. + +## 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 Wayland GPU acceleration. + +**Symptoms:** +``` +ERROR:viz_main_impl.cc:189] Exiting GPU process due to errors during initialization +``` + +**Automatic Recovery:** Starting with version 4.10.2, the app automatically detects this crash and switches to X11 mode. You should see the app restart and work normally after the first crash. + +**Manual Solution:** If auto-recovery doesn't work, force X11 mode: +```bash +rocketchat-desktop --ozone-platform=x11 +``` + +**Settings Option:** Go to Settings > General > Display Server Mode and select "Force X11". + +### 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 (if system supports it) +rocketchat-desktop --ozone-platform=wayland +``` + +## 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 +```bash +rocketchat-desktop --enable-logging --v=1 +``` + +### Disable GPU Acceleration (last resort) +```bash +rocketchat-desktop --disable-gpu +``` + +## Resetting Display Server Mode + +If the app auto-switched to X11 and you want to try Wayland again (e.g., after updating GPU drivers): + +1. Open the app (it will run in X11 mode) +2. Go to Settings > General +3. Find "Display Server Mode" +4. Select "Auto-detect (Wayland/X11)" +5. The app will restart and attempt Wayland + +If the GPU crashes again, the app will automatically switch back to X11. + +## Best Practices + +- **Real Hardware + Wayland**: Use default (auto-detection) for best experience +- **VM + Wayland**: The app will auto-detect and switch to X11 after first crash +- **X11 Session**: No configuration needed, works automatically +- **Snap Package**: X11 is forced automatically, no action needed + +## 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 db9147b132..5939d1048d 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -164,7 +164,8 @@ "screen-inhibit-control", "upower-observe", "pipewire" - ] + ], + "executableArgs": ["--ozone-platform=x11"] }, "afterSign": "./build/notarize.js", "generateUpdatesFilesForAllChannels": true, diff --git a/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index daa692be88..492a47dc1f 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -92,9 +92,15 @@ type PersistableValues_4_9_0 = PersistableValues_4_7_2 & { isVideoCallScreenCaptureFallbackEnabled: boolean; }; +export type GpuFallbackMode = 'none' | 'x11' | 'disabled'; + +type PersistableValues_4_10_2 = PersistableValues_4_9_0 & { + gpuFallbackMode: GpuFallbackMode; +}; + export type PersistableValues = Pick< - PersistableValues_4_9_0, - keyof PersistableValues_4_9_0 + PersistableValues_4_10_2, + keyof PersistableValues_4_10_2 >; export const migrations = { @@ -173,4 +179,8 @@ export const migrations = { isTransparentWindowEnabled: false, isVideoCallScreenCaptureFallbackEnabled: false, }), + '>=4.10.2': (before: PersistableValues_4_9_0): PersistableValues_4_10_2 => ({ + ...before, + gpuFallbackMode: 'none', + }), }; diff --git a/src/app/main/app.ts b/src/app/main/app.ts index 4d17b582d3..adac08b3e0 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; + import { app, session, BrowserWindow } from 'electron'; import { rimraf } from 'rimraf'; @@ -11,9 +14,14 @@ import electronBuilderJson from '../../../electron-builder.json'; import packageJson from '../../../package.json'; import { JITSI_SERVER_CAPTURE_SCREEN_PERMISSIONS_CLEARED } from '../../jitsi/actions'; import { dispatch, listen } from '../../store'; -import { readSetting } from '../../store/readSetting'; +import { + readGpuFallbackMode, + readSetting, + saveGpuFallbackMode, +} from '../../store/readSetting'; import { SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS, + SETTINGS_GPU_FALLBACK_MODE_CHANGED, SETTINGS_NTLM_CREDENTIALS_CHANGED, SETTINGS_SET_HARDWARE_ACCELERATION_OPT_IN_CHANGED, SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED, @@ -41,6 +49,48 @@ export const electronBuilderJsonInformation = { }; let isScreenCaptureFallbackForced = false; +let isMainWindowStable = false; + +const STARTUP_SENTINEL_FILE = 'startup-sentinel.json'; +const MAX_CRASH_COUNT = 2; +const CRASH_WINDOW_MS = 60000; // 1 minute + +interface IStartupSentinel { + crashCount: number; + lastCrashTime: number; +} + +const getStartupSentinelPath = (): string => + path.join(app.getPath('userData'), STARTUP_SENTINEL_FILE); + +const readStartupSentinel = (): IStartupSentinel | null => { + try { + const content = fs.readFileSync(getStartupSentinelPath(), 'utf8'); + return JSON.parse(content) as IStartupSentinel; + } catch { + return null; + } +}; + +const writeStartupSentinel = (sentinel: IStartupSentinel): void => { + try { + fs.writeFileSync( + getStartupSentinelPath(), + JSON.stringify(sentinel), + 'utf8' + ); + } catch (error) { + console.error('Failed to write startup sentinel:', error); + } +}; + +const clearStartupSentinel = (): void => { + try { + fs.unlinkSync(getStartupSentinelPath()); + } catch { + // File doesn't exist, ignore + } +}; export const getPlatformName = (): string => { switch (process.platform) { @@ -142,6 +192,56 @@ export const performElectronStartup = (): void => { // Enable PipeWire screen capture for Linux (Wayland support) if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'WebRTCPipeWireCapturer'); + + // Check startup sentinel for crash detection + let gpuFallbackMode = readGpuFallbackMode(); + + if (gpuFallbackMode === 'none') { + // Not in fallback mode, check for repeated crashes + const sentinel = readStartupSentinel(); + const now = Date.now(); + + if (sentinel) { + const timeSinceLastCrash = now - sentinel.lastCrashTime; + + if (timeSinceLastCrash < CRASH_WINDOW_MS) { + // Recent crash detected + const newCrashCount = sentinel.crashCount + 1; + + if (newCrashCount >= MAX_CRASH_COUNT) { + // Too many crashes, enable fallback + console.log( + `Detected ${newCrashCount} crashes in ${CRASH_WINDOW_MS}ms, enabling X11 fallback` + ); + saveGpuFallbackMode('x11'); + gpuFallbackMode = 'x11'; + clearStartupSentinel(); + } else { + // Increment crash count + writeStartupSentinel({ + crashCount: newCrashCount, + lastCrashTime: now, + }); + } + } else { + // Crash was too long ago, reset counter + writeStartupSentinel({ crashCount: 1, lastCrashTime: now }); + } + } else { + // No previous sentinel, create one + writeStartupSentinel({ crashCount: 1, lastCrashTime: now }); + } + } + + // Apply GPU fallback mode + if (gpuFallbackMode === 'x11') { + console.log('GPU fallback mode: forcing X11 display server'); + app.commandLine.appendSwitch('ozone-platform', 'x11'); + } else if (gpuFallbackMode === 'disabled') { + console.log('GPU fallback mode: GPU disabled'); + app.disableHardwareAcceleration(); + app.commandLine.appendSwitch('disable-gpu'); + } } }; @@ -152,6 +252,55 @@ export const initializeScreenCaptureFallbackState = (): void => { }); }; +export const markMainWindowStable = (): void => { + isMainWindowStable = true; + + // Clear startup sentinel on successful startup (Linux only) + if (process.platform === 'linux') { + clearStartupSentinel(); + } +}; + +export const setupGpuCrashHandler = (): void => { + if (process.platform !== 'linux') { + return; + } + + const currentFallbackMode = readGpuFallbackMode(); + if (currentFallbackMode !== 'none') { + // Already in fallback mode, don't set up crash handler + return; + } + + app.on('child-process-gone', (_event, details) => { + // Only handle GPU process crashes + if (details.type !== 'GPU') { + return; + } + + // Only trigger fallback during early startup (before main window is stable) + if (isMainWindowStable) { + console.log( + 'GPU process crashed after main window stable, not triggering fallback' + ); + return; + } + + console.log( + 'GPU process crashed during startup, details:', + JSON.stringify(details) + ); + + // Save fallback mode and relaunch + saveGpuFallbackMode('x11'); + console.log('GPU fallback mode set to x11, relaunching...'); + + const command = process.argv.slice(1, app.isPackaged ? 1 : 2); + app.relaunch({ args: command }); + app.exit(0); + }); +}; + export const setupApp = (): void => { app.addListener('activate', async () => { try { @@ -196,6 +345,15 @@ export const setupApp = (): void => { relaunchApp(); }); + // Linux only: handle GPU fallback mode changes + listen(SETTINGS_GPU_FALLBACK_MODE_CHANGED, (action) => { + if (process.platform !== 'linux') { + return; + } + saveGpuFallbackMode(action.payload); + relaunchApp(); + }); + listen( SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED, (action) => { diff --git a/src/app/selectors.ts b/src/app/selectors.ts index 5b66c5c9cb..bcada9c868 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -71,4 +71,5 @@ export const selectPersistableValues = createStructuredSelector({ isVideoCallScreenCaptureFallbackEnabled, }: RootState) => isVideoCallScreenCaptureFallbackEnabled, updateChannel: ({ updateChannel }: RootState) => updateChannel, + gpuFallbackMode: ({ gpuFallbackMode }: RootState) => gpuFallbackMode, }); diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index ca2d9343b8..87f32a69b0 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -239,6 +239,15 @@ "title": "Hardware Acceleration", "description": "Enables the use of hardware acceleration when available. The application will reload on change." }, + "gpuFallbackMode": { + "title": "Display Server Mode", + "description": "Controls how the app connects to your display. Auto-detect works on most systems. If the app crashes on startup, it will automatically switch to Force X11. The app will restart on change.", + "options": { + "auto": "Auto-detect (Wayland/X11)", + "x11": "Force X11", + "disabled": "Disable GPU" + } + }, "videoCallScreenCaptureFallback": { "title": "Fallback Screen Capture for Video Calls", "description": "Disable Windows Graphics Capture so screen sharing works in Remote Desktop sessions. The app restarts when you change this option.", diff --git a/src/main.ts b/src/main.ts index 1819013726..7042b52ca3 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, @@ -54,6 +56,9 @@ const start = async (): Promise => { await app.whenReady(); + // Set up GPU crash handler early to catch crashes during startup + setupGpuCrashHandler(); + createMainReduxStore(); // Initialize screen capture fallback state after store is available @@ -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/store/readSetting.ts b/src/store/readSetting.ts index f41a7772ac..75ad1a91e0 100644 --- a/src/store/readSetting.ts +++ b/src/store/readSetting.ts @@ -3,6 +3,8 @@ import path from 'path'; import { app } from 'electron'; +import type { GpuFallbackMode } from '../app/PersistableValues'; + export const readSetting = (key: string) => { try { const filePath = path.join(app.getPath('userData'), 'config.json'); @@ -14,3 +16,30 @@ export const readSetting = (key: string) => { return null; } }; + +export const readGpuFallbackMode = (): GpuFallbackMode => { + const value = readSetting('gpuFallbackMode'); + if (value === 'x11' || value === 'disabled') { + return value; + } + return 'none'; +}; + +export const saveGpuFallbackMode = (mode: GpuFallbackMode): void => { + try { + const filePath = path.join(app.getPath('userData'), 'config.json'); + let json: Record = {}; + + try { + const content = fs.readFileSync(filePath, 'utf8'); + json = JSON.parse(content); + } catch { + // File doesn't exist or is invalid, start fresh + } + + json.gpuFallbackMode = mode; + fs.writeFileSync(filePath, JSON.stringify(json, null, 2), 'utf8'); + } catch (error) { + console.error('Failed to save GPU fallback mode:', error); + } +}; diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index ed2a06bd18..14224c3a2e 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -18,6 +18,7 @@ import { servers } from '../servers/reducers'; import { availableBrowsers } from '../ui/reducers/availableBrowsers'; import { currentView } from '../ui/reducers/currentView'; import { dialogs } from '../ui/reducers/dialogs'; +import { gpuFallbackMode } from '../ui/reducers/gpuFallbackMode'; import { hasHideOnTrayNotificationShown } from '../ui/reducers/hasHideOnTrayNotificationShown'; import { isAddNewServersEnabled } from '../ui/reducers/isAddNewServersEnabled'; import { isDeveloperModeEnabled } from '../ui/reducers/isDeveloperModeEnabled'; @@ -65,6 +66,7 @@ export const rootReducer = combineReducers({ doCheckForUpdatesOnStartup, downloads, externalProtocols, + gpuFallbackMode, isCheckingForUpdates, isEachUpdatesSettingConfigurable, isMenuBarEnabled, diff --git a/src/ui/actions.ts b/src/ui/actions.ts index 53ad9ac707..183afa79a0 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -1,5 +1,6 @@ import type { WebContents } from 'electron'; +import type { GpuFallbackMode } from '../app/PersistableValues'; import type { Server } from '../servers/common'; import type { RootWindowIcon, WindowState } from './common'; @@ -116,6 +117,8 @@ export const SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS = 'settings/clear-permitted-screen-capture-permissions'; export const SETTINGS_NTLM_CREDENTIALS_CHANGED = 'settings/ntlm-credentials-changed'; +export const SETTINGS_GPU_FALLBACK_MODE_CHANGED = + 'settings/gpu-fallback-mode-changed'; export const SETTINGS_AVAILABLE_BROWSERS_UPDATED = 'settings/available-browsers-updated'; export const SETTINGS_SELECTED_BROWSER_CHANGED = @@ -255,6 +258,7 @@ export type UiActionTypeToPayloadMap = { [SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED]: boolean; [SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS]: void; [SETTINGS_NTLM_CREDENTIALS_CHANGED]: boolean; + [SETTINGS_GPU_FALLBACK_MODE_CHANGED]: GpuFallbackMode; [SETTINGS_AVAILABLE_BROWSERS_UPDATED]: string[]; [SETTINGS_SELECTED_BROWSER_CHANGED]: string | null; [SET_HAS_TRAY_MINIMIZE_NOTIFICATION_SHOWN]: boolean; diff --git a/src/ui/components/SettingsView/GeneralTab.tsx b/src/ui/components/SettingsView/GeneralTab.tsx index 6f85b1356d..47ef67ece2 100644 --- a/src/ui/components/SettingsView/GeneralTab.tsx +++ b/src/ui/components/SettingsView/GeneralTab.tsx @@ -3,6 +3,7 @@ import { Box, FieldGroup } from '@rocket.chat/fuselage'; import { AvailableBrowsers } from './features/AvailableBrowsers'; import { ClearPermittedScreenCaptureServers } from './features/ClearPermittedScreenCaptureServers'; import { FlashFrame } from './features/FlashFrame'; +import { GpuFallbackMode } from './features/GpuFallbackMode'; import { HardwareAcceleration } from './features/HardwareAcceleration'; import { InternalVideoChatWindow } from './features/InternalVideoChatWindow'; import { MenuBar } from './features/MenuBar'; @@ -21,6 +22,7 @@ export const GeneralTab = () => ( + {process.platform === 'linux' && } {process.platform === 'win32' && } diff --git a/src/ui/components/SettingsView/features/GpuFallbackMode.tsx b/src/ui/components/SettingsView/features/GpuFallbackMode.tsx new file mode 100644 index 0000000000..08231bd7e8 --- /dev/null +++ b/src/ui/components/SettingsView/features/GpuFallbackMode.tsx @@ -0,0 +1,78 @@ +import { + Field, + FieldRow, + FieldLabel, + FieldHint, + Select, +} from '@rocket.chat/fuselage'; +import type { Key } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { GpuFallbackMode as GpuFallbackModeType } from '../../../../app/PersistableValues'; +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { SETTINGS_GPU_FALLBACK_MODE_CHANGED } from '../../../actions'; + +type GpuFallbackModeProps = { + className?: string; +}; + +export const GpuFallbackMode = (props: GpuFallbackModeProps) => { + const gpuFallbackMode = useSelector( + ({ gpuFallbackMode }: RootState) => gpuFallbackMode + ); + const dispatch = useDispatch>(); + const { t } = useTranslation(); + + const handleChange = useCallback( + (value: Key) => { + const stringValue = String(value); + if ( + stringValue === 'none' || + stringValue === 'x11' || + stringValue === 'disabled' + ) { + dispatch({ + type: SETTINGS_GPU_FALLBACK_MODE_CHANGED, + payload: stringValue as GpuFallbackModeType, + }); + } + }, + [dispatch] + ); + + const options = useMemo( + (): [string, string][] => [ + ['none', t('settings.options.gpuFallbackMode.options.auto')], + ['x11', t('settings.options.gpuFallbackMode.options.x11')], + ['disabled', t('settings.options.gpuFallbackMode.options.disabled')], + ], + [t] + ); + + // Only show on Linux + if (process.platform !== 'linux') { + return null; + } + + return ( + + + {t('settings.options.gpuFallbackMode.title')} + - - - - {t('settings.options.gpuFallbackMode.description')} - - - - ); -}; diff --git a/src/ui/reducers/gpuFallbackMode.ts b/src/ui/reducers/gpuFallbackMode.ts deleted file mode 100644 index 42c598f792..0000000000 --- a/src/ui/reducers/gpuFallbackMode.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Reducer } from 'redux'; - -import type { GpuFallbackMode } from '../../app/PersistableValues'; -import { APP_SETTINGS_LOADED } from '../../app/actions'; -import type { ActionOf } from '../../store/actions'; -import { SETTINGS_GPU_FALLBACK_MODE_CHANGED } from '../actions'; - -type GpuFallbackModeAction = ActionOf< - typeof SETTINGS_GPU_FALLBACK_MODE_CHANGED ->; - -const isValidFallbackMode = (value: unknown): value is GpuFallbackMode => - value === 'none' || - value === 'x11' || - value === 'wayland' || - value === 'disabled'; - -export const gpuFallbackMode: Reducer< - GpuFallbackMode, - GpuFallbackModeAction | ActionOf -> = (state = 'none', action) => { - switch (action.type) { - case APP_SETTINGS_LOADED: { - const value = action.payload.gpuFallbackMode; - return isValidFallbackMode(value) ? value : 'none'; - } - case SETTINGS_GPU_FALLBACK_MODE_CHANGED: { - return isValidFallbackMode(action.payload) ? action.payload : state; - } - default: - return state; - } -}; From b3615b763c4dbb6ec78d40356da8b29f7feb64aa Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 23 Dec 2025 20:30:21 -0300 Subject: [PATCH 32/33] fix(linux): Add proactive GPU detection with X11 fallback - Remove sentinel file system (crash counting, timing windows) - Add gpu-info-update listener to detect GPU issues early - Check gpu_compositing and webgl status for disabled/unavailable states - Relaunch with --disable-gpu --ozone-platform=x11 when GPU is broken - Simplify crash handler to immediately trigger fallback on GPU crash --- src/app/main/app.ts | 99 ++++++++++----------------------------------- 1 file changed, 22 insertions(+), 77 deletions(-) diff --git a/src/app/main/app.ts b/src/app/main/app.ts index 9f7187c079..1e71c1f764 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -1,6 +1,4 @@ import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; import { app, session, BrowserWindow } from 'electron'; import { rimraf } from 'rimraf'; @@ -45,48 +43,6 @@ export const electronBuilderJsonInformation = { }; let isScreenCaptureFallbackForced = false; -let isMainWindowStable = false; - -const STARTUP_SENTINEL_FILE = 'startup-sentinel.json'; -const CRASH_THRESHOLD = 2; -const CRASH_WINDOW_MS = 60000; // 1 minute - -interface IStartupSentinel { - crashCount: number; - lastCrashTime: number; -} - -const getStartupSentinelPath = (): string => - path.join(app.getPath('userData'), STARTUP_SENTINEL_FILE); - -const readStartupSentinel = (): IStartupSentinel | null => { - try { - const content = fs.readFileSync(getStartupSentinelPath(), 'utf8'); - return JSON.parse(content) as IStartupSentinel; - } catch { - return null; - } -}; - -const writeStartupSentinel = (sentinel: IStartupSentinel): void => { - try { - fs.writeFileSync( - getStartupSentinelPath(), - JSON.stringify(sentinel), - 'utf8' - ); - } catch (error) { - console.error('Failed to write startup sentinel:', error); - } -}; - -const clearStartupSentinel = (): void => { - try { - fs.unlinkSync(getStartupSentinelPath()); - } catch { - // File doesn't exist, ignore - } -}; export const getPlatformName = (): string => { switch (process.platform) { @@ -217,12 +173,7 @@ export const initializeScreenCaptureFallbackState = (): void => { }; export const markMainWindowStable = (): void => { - isMainWindowStable = true; - - // Clear startup sentinel on successful startup (Linux only) - if (process.platform === 'linux') { - clearStartupSentinel(); - } + // No-op: kept for API compatibility }; export const setupGpuCrashHandler = (): void => { @@ -230,50 +181,44 @@ export const setupGpuCrashHandler = (): void => { return; } - // Skip if GPU is already disabled 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; } - if (isMainWindowStable) { - console.log( - 'GPU process crashed after main window stable, not triggering fallback' - ); - return; - } - - console.log( - 'GPU process crashed during startup, details:', - JSON.stringify(details) - ); + 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); + }); - const sentinel = readStartupSentinel(); - const now = Date.now(); - let newCrashCount = 1; + // 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; - if (sentinel && now - sentinel.lastCrashTime < CRASH_WINDOW_MS) { - newCrashCount = sentinel.crashCount + 1; - } + // 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 (newCrashCount >= CRASH_THRESHOLD) { + if (isGpuBroken(gpuCompositing) || isGpuBroken(webgl)) { console.log( - `GPU crash threshold reached (${newCrashCount} >= ${CRASH_THRESHOLD}), disabling GPU acceleration` + 'GPU features unavailable, disabling GPU and relaunching with X11', + JSON.stringify(gpuFeatures) ); - clearStartupSentinel(); const userArgs = process.argv.slice(app.isPackaged ? 1 : 2); - relaunchApp('--disable-gpu', ...userArgs); - } else { - writeStartupSentinel({ crashCount: newCrashCount, lastCrashTime: now }); - console.log( - `GPU crash ${newCrashCount}/${CRASH_THRESHOLD} - will disable GPU after ${CRASH_THRESHOLD} crashes` - ); + relaunchApp('--disable-gpu', '--ozone-platform=x11', ...userArgs); + return; } + + console.log('GPU features status:', JSON.stringify(gpuFeatures)); }); }; From ca1b08fd266b67b981a86ea8c2667a0f89ee39c2 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 23 Dec 2025 20:30:33 -0300 Subject: [PATCH 33/33] fix(ci): Fix AppImage upload and PR comment updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix case sensitivity: rocketchat-*.appimage → rocketchat-*.AppImage (AWS CLI on Linux is case-sensitive, file wasn't being uploaded) - Fix sticky comment header to use simple identifier instead of markdown - Remove redundant recreate/append flags (defaults work correctly) --- .github/workflows/pull-request-build.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pull-request-build.yml b/.github/workflows/pull-request-build.yml index 62e1568fc9..3fb9a8bd6a 100644 --- a/.github/workflows/pull-request-build.yml +++ b/.github/workflows/pull-request-build.yml @@ -586,7 +586,7 @@ jobs: --endpoint-url=https://s3.us-east-1.wasabisys.com \ --exclude "*" \ --include "rocketchat-*.snap" \ - --include "rocketchat-*.appimage" \ + --include "rocketchat-*.AppImage" \ --include "rocketchat-*.deb" env: AWS_ACCESS_KEY_ID: ${{ secrets.WASABI_ACCESS_KEY_ID }} @@ -617,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