From fff583fca513610b811e99b89ef152147bbd653a Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 27 Sep 2026 16:54:29 -0400 Subject: [PATCH] feat(call): desktop without WebRTC offers "Open in browser" instead of a dead end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52 has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc backend around 2.56), so calls can't work there. Until now the call button just disappeared, the call room said "Your browser does not support WebRTC" with Join disabled, and an incoming call couldn't be answered. In the desktop app (isTauri) without WebRTC: - call rooms: "Calls aren't available in the desktop app on Linux yet: its web engine has no WebRTC" + an "Open in browser" button; - incoming-call overlay: the same, with "Answer in browser"; - room header: the call button stays, and opens the room in the browser. The link is the room in the web app (config.json `webAppUrl`, https only, new key); the user presses Join there. Deliberately not an auto-join link: a crafted URL must not be able to join a call and open someone's mic. It opens through the desktop's new-window handler (web/mail schemes only → the system browser). Without `webAppUrl` the explanation shows with no button; browsers without WebRTC keep the old message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/CallEmbedProvider.tsx | 9 +-- src/app/features/call/CallView.tsx | 14 ++-- src/app/features/call/CallsUnavailable.tsx | 76 ++++++++++++++++++++++ src/app/features/room/RoomViewHeader.tsx | 41 +++++++++++- src/app/hooks/useClientConfig.ts | 6 ++ src/app/utils/callInBrowser.test.ts | 32 +++++++++ src/app/utils/callInBrowser.ts | 27 ++++++++ 7 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 src/app/features/call/CallsUnavailable.tsx create mode 100644 src/app/utils/callInBrowser.test.ts create mode 100644 src/app/utils/callInBrowser.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index c74705a85..c2f38bbdb 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -82,6 +82,7 @@ import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy'; import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet'; import { CallAvatarAnimation } from '../styles/Animations.css'; import { webRTCSupported } from '../utils/rtc'; +import { CallsUnavailableMessage } from '../features/call/CallsUnavailable'; import { zIndices } from '../styles/zIndex'; const PIP_MIN_W = 200; @@ -231,13 +232,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr )} {!webRTCSupported() && ( - - Your browser does not support WebRTC, which is required for calling. - + )} {willAnswerWithCamera && ( diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 7611aa39d..f4e21a5ab 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -23,6 +23,7 @@ import { CallMemberRenderer } from './CallMemberCard'; import * as css from './styles.css'; import { CallControls } from './CallControls'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; +import { CallsUnavailableMessage } from './CallsUnavailable'; import { webRTCSupported } from '../../utils/rtc'; function LivekitServerMissingMessage() { @@ -33,25 +34,19 @@ function LivekitServerMissingMessage() { ); } -function WebRTCMissingError() { - return ( - - Your browser does not support WebRTC, which is required for calling. - - ); -} - function JoinMessage({ + roomId, hasParticipant, livekitSupported, rtcSupported, }: { + roomId: string; hasParticipant?: boolean; livekitSupported?: boolean; rtcSupported?: boolean; }) { if (rtcSupported === false) { - return ; + return ; } if (livekitSupported === false) { @@ -144,6 +139,7 @@ function CallPrescreen() { )} {!inOtherCall && hasPermission && !channelFull && ( { + const { webAppUrl } = useClientConfig(); + return useMemo( + () => + isTauri() && !webRTCSupported() + ? getCallInBrowserUrl(resolveWebAppUrl(webAppUrl), roomId) + : undefined, + [webAppUrl, roomId], + ); +}; + +/** Opens through the desktop's new-window handler → the system browser. */ +export const openCallInBrowser = (url: string): void => { + window.open(url, '_blank', 'noopener,noreferrer'); +}; + +const desktopUnavailableText = (): string => + typeof navigator !== 'undefined' && /Linux/i.test(navigator.userAgent) + ? 'Calls aren’t available in the desktop app on Linux yet: its web engine has no WebRTC.' + : 'Calls aren’t available in this desktop app: its web engine has no WebRTC.'; + +type CallsUnavailableMessageProps = { + roomId: string; + /** Button label, e.g. "Answer in browser" for an incoming call. */ + actionLabel?: string; +}; + +export function CallsUnavailableMessage({ + roomId, + actionLabel = 'Open in browser', +}: CallsUnavailableMessageProps) { + const url = useCallInBrowserUrl(roomId); + + if (!isTauri()) { + return ( + + Your browser does not support WebRTC, which is required for calling. + + ); + } + + return ( + + + {desktopUnavailableText()} + {url ? ' You can join from the web app in your browser.' : ''} + + {url && ( + + )} + + ); +} diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 38a75ff05..fe6847da4 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -80,6 +80,7 @@ import { widgetsPanelAtom } from '../../state/widgetsPanel'; import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel'; import { threadsListAtom } from '../../state/threadsList'; import { usePendingKnocks } from '../../hooks/usePendingKnocks'; +import { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable'; import { bookmarksPanelAtom } from '../../state/bookmarksPanel'; type RoomMenuProps = { @@ -465,6 +466,36 @@ function CallRulesChip({ room }: { room: Room }) { ); } +/** + * [cinny-desktop: Linux calls] Stand-in for CallButton where this app can't + * make calls (desktop on WebKitGTK): opens the room in the web app instead. + */ +function CallInBrowserButton({ url }: { url: string }) { + return ( + + Call in your browser (this desktop app can’t make calls here) + + } + > + {(triggerRef) => ( + openCallInBrowser(url)} + > + + + )} + + ); +} + function CallButton() { const room = useRoom(); const direct = useIsDirectRoom(); @@ -555,6 +586,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { ); const livekitSupported = useLivekitSupport(); const rtcSupported = webRTCSupported(); + const callInBrowserUrl = useCallInBrowserUrl(room.roomId); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); @@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { {screenSize === ScreenSize.Desktop && } {!room.isCallRoom() && livekitSupported && - rtcSupported && + (rtcSupported || callInBrowserUrl) && hasCallPermission && (direct || (room.getJoinRule() === 'invite' && - getStateEvents(room, StateEvent.SpaceParent).length === 0)) && } + getStateEvents(room, StateEvent.SpaceParent).length === 0)) && + (rtcSupported ? ( + + ) : ( + callInBrowserUrl && + ))} {screenSize === ScreenSize.Desktop && ( (null); diff --git a/src/app/utils/callInBrowser.test.ts b/src/app/utils/callInBrowser.test.ts new file mode 100644 index 000000000..9f6b6287c --- /dev/null +++ b/src/app/utils/callInBrowser.test.ts @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getCallInBrowserUrl, resolveWebAppUrl } from './callInBrowser'; + +test('resolveWebAppUrl keeps an https origin (+ path) without a trailing slash', () => { + assert.equal(resolveWebAppUrl('https://chat.example.org'), 'https://chat.example.org'); + assert.equal(resolveWebAppUrl('https://chat.example.org/'), 'https://chat.example.org'); + assert.equal(resolveWebAppUrl('https://example.org/chat/'), 'https://example.org/chat'); + assert.equal(resolveWebAppUrl('https://chat.example.org/?x=1#y'), 'https://chat.example.org'); +}); + +test('resolveWebAppUrl rejects anything but absolute https', () => { + [ + undefined, + null, + 1, + '', + ' ', + '/home', + 'http://chat.example.org', + 'not a url', + ['javascript', 'alert(1)'].join(':'), + 'data:text/html,x', + ].forEach((v) => assert.equal(resolveWebAppUrl(v), undefined, String(v))); +}); + +test('getCallInBrowserUrl points at the room in the web app (no auto-join)', () => { + const url = getCallInBrowserUrl('https://chat.example.org', '!room:example.org'); + assert.equal(url, 'https://chat.example.org/home/!room%3Aexample.org'); + assert.ok(!/join|call=/i.test(url ?? '')); + assert.equal(getCallInBrowserUrl(undefined, '!room:example.org'), undefined); +}); diff --git a/src/app/utils/callInBrowser.ts b/src/app/utils/callInBrowser.ts new file mode 100644 index 000000000..721cada66 --- /dev/null +++ b/src/app/utils/callInBrowser.ts @@ -0,0 +1,27 @@ +import { getLotusRoomPermalink } from '../plugins/lotus-permalink'; + +/** + * The public web app's address from config.json `webAppUrl` (the desktop app + * sets it; its own origin is a local server). Absolute https only; anything + * else is ignored. + */ +export const resolveWebAppUrl = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.trim() === '') return undefined; + try { + const url = new URL(value); + if (url.protocol !== 'https:') return undefined; + return url.origin + url.pathname.replace(/\/+$/, ''); + } catch { + return undefined; + } +}; + +/** + * Where to send the user to take a call this app can't make: the room in the + * web app (they press Join there themselves — never an auto-join link, which + * a crafted URL could abuse to open someone's mic). + */ +export const getCallInBrowserUrl = ( + webAppUrl: string | undefined, + roomId: string, +): string | undefined => (webAppUrl ? getLotusRoomPermalink(webAppUrl, roomId) : undefined);