From bf61ccedc7597e81d11d35861560bbd0dd3c9b8d Mon Sep 17 00:00:00 2001 From: jwcrystal <121911854+jwcrystal@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:11:45 +0800 Subject: [PATCH] fix: external links in desktop app - context menu and open behavior (#716) * fix: allow native context menu on links in chat messages The desktop app was blocking the context menu on all elements except specific allowlisted ones (terminal, input, textarea, etc.). This prevented users from right-clicking on HTTP links in chat messages to access the 'Open Link' option. Added 'a' (anchor) tag to the allowlist to restore native context menu functionality for links. Fixes #708 * fix: use tauri.shell.open for external links in desktop app - Add global window.open override to init_script that routes HTTP/HTTPS URLs through tauri.shell.open() instead of window.open() - Add openExternalUrl utility that prefers tauri.shell.open with window.open fallback - Add openExternalUrl to MarkdownRenderer for link safety.onLinkCheck - Replace window.open with openExternalUrl in ProvidersPage for OAuth URLs Fixes #708 * fix: unify external link opening across desktop and UI Added a shared URL opener that only allows http/https links. Replaced duplicated Tauri/window link-open logic in key UI sections. Removed fragile desktop window.open override and markdown external-link modal behavior. --------- Co-authored-by: Bohdan Triapitsyn --- packages/desktop/src-tauri/src/main.rs | 2 +- .../src/components/chat/MarkdownRenderer.tsx | 72 ++++++++++++++++-- .../layout/ProjectActionsButton.tsx | 18 +---- .../sections/openchamber/GitHubSettings.tsx | 22 +----- .../sections/openchamber/TunnelSettings.tsx | 22 +----- .../sections/providers/ProvidersPage.tsx | 7 +- .../remote-instances/RemoteInstancesPage.tsx | 32 +------- .../session/sidebar/SessionGroupSection.tsx | 12 +-- .../ui/src/components/ui/UpdateDialog.tsx | 21 +----- .../views/git/PullRequestSection.tsx | 27 +------ packages/ui/src/lib/url.ts | 74 +++++++++++++++++++ 11 files changed, 158 insertions(+), 151 deletions(-) create mode 100644 packages/ui/src/lib/url.ts diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index db3edd60..a2c69e72 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -2356,7 +2356,7 @@ fn build_init_script(local_origin: &str) -> String { init_script.push_str("\ntry{var old=document.getElementById('__oc-instance-switcher');if(old)old.remove();}catch(_e){}"); if !cfg!(debug_assertions) { - init_script.push_str("\ntry{document.addEventListener('contextmenu',function(e){var t=e&&e.target;if(!t||typeof t.closest!=='function'){e.preventDefault();return;}if(t.closest('.terminal-viewport-container,[data-oc-allow-native-contextmenu],input,textarea,[contenteditable=\"true\"]')){return;}e.preventDefault();},true);}catch(_e){}"); + init_script.push_str("\ntry{document.addEventListener('contextmenu',function(e){var t=e&&e.target;if(!t||typeof t.closest!=='function'){e.preventDefault();return;}if(t.closest('.terminal-viewport-container,[data-oc-allow-native-contextmenu],a,input,textarea,[contenteditable=\"true\"]')){return;}e.preventDefault();},true);}catch(_e){}"); } init_script diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 4a442bfb..2a87a108 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -11,6 +11,7 @@ import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; @@ -152,6 +153,59 @@ const useCurrentMermaidTheme = () => { : fallbackLight); }; +const useExternalLinkInteractions = ({ + containerRef, + enabled, +}: { + containerRef: React.RefObject; + enabled?: boolean; +}) => { + React.useEffect(() => { + if (enabled === false) { + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + + const handleClick = (event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { + return; + } + + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + + if (anchor.getAttribute('data-openchamber-file-link') === 'true') { + return; + } + + const href = anchor.getAttribute('href') ?? ''; + if (!isExternalHttpUrl(href)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void openExternalUrl(href); + }; + + container.addEventListener('click', handleClick); + return () => { + container.removeEventListener('click', handleClick); + }; + }, [containerRef, enabled]); +}; + // Table utility functions const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => { const headers: string[] = []; @@ -1356,6 +1410,7 @@ export const MarkdownRenderer: React.FC = ({ preferRuntimeEditor: runtime.isVSCode, deferValidationUntilIdle: isStreaming, }); + useExternalLinkInteractions({ containerRef: streamdownContainerRef }); const shikiThemes = useMarkdownShikiThemes(); const streamdownPlugins = useStreamdownPlugins(shikiThemes); @@ -1377,12 +1432,14 @@ export const MarkdownRenderer: React.FC = ({ mode={isStreaming && !disableStreamAnimation ? 'streaming' : 'static'} shikiTheme={shikiThemes} className={streamdownClassName} - controls={streamdownControls} - plugins={streamdownPlugins} - components={streamdownComponents} - animated={disableStreamAnimation ? undefined : streamdownAnimated} - isAnimating={disableStreamAnimation ? false : isStreaming} - > + controls={streamdownControls} + plugins={streamdownPlugins} + components={streamdownComponents} + animated={disableStreamAnimation ? undefined : streamdownAnimated} + isAnimating={disableStreamAnimation ? false : isStreaming} + // @ts-expect-error Streamdown type missing linkSafety in older minor + linkSafety={{ enabled: false }} + > {content} @@ -1438,6 +1495,7 @@ export const SimpleMarkdownRenderer: React.FC<{ editor, preferRuntimeEditor: runtime.isVSCode, }); + useExternalLinkInteractions({ containerRef: streamdownContainerRef, enabled: !disableLinkSafety }); const shikiThemes = useMarkdownShikiThemes(); const streamdownPlugins = useStreamdownPlugins(shikiThemes); @@ -1460,7 +1518,7 @@ export const SimpleMarkdownRenderer: React.FC<{ plugins={streamdownPlugins} components={streamdownComponents} // @ts-expect-error Streamdown type missing linkSafety in older minor - linkSafety={disableLinkSafety ? { enabled: false } : undefined} + linkSafety={{ enabled: false }} > {renderedContent} diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index ccb1e6f9..73038905 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -21,6 +21,7 @@ import { isDesktopShell } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; +import { openExternalUrl } from '@/lib/url'; import { getProjectActionsState, type OpenChamberProjectAction, @@ -225,22 +226,7 @@ export const ProjectActionsButton = ({ }, [isDesktopShellApp, loadDesktopSsh]); const openExternal = React.useCallback(async (url: string) => { - try { - const tauri = (window as unknown as { - __TAURI__?: { - shell?: { - open?: (target: string) => Promise; - }; - }; - }).__TAURI__; - if (tauri?.shell?.open) { - await tauri.shell.open(url); - return; - } - } catch { - // noop - } - window.open(url, '_blank', 'noopener,noreferrer'); + await openExternalUrl(url); }, []); const loadActions = React.useCallback(async () => { diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index bc218122..26467e60 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -6,6 +6,7 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import type { GitHubAuthStatus } from '@/lib/api/types'; import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; +import { openExternalUrl } from '@/lib/url'; import { RiGithubFill, RiInformationLine } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -41,26 +42,7 @@ export const GitHubSettings: React.FC = () => { const setStatus = useGitHubAuthStore((state) => state.setStatus); const openExternal = React.useCallback(async (url: string) => { - if (typeof window === 'undefined') { - return; - } - - type TauriShell = { shell?: { open?: (url: string) => Promise } }; - const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; - if (tauri?.shell?.open) { - try { - await tauri.shell.open(url); - return; - } catch { - // fall through - } - } - - try { - window.open(url, '_blank', 'noopener,noreferrer'); - } catch { - // ignore - } + await openExternalUrl(url); }, []); const [isBusy, setIsBusy] = React.useState(false); diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx index 2186ae39..e3702240 100644 --- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx @@ -27,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { requestFileAccess } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { cn } from '@/lib/utils'; +import { openExternalUrl } from '@/lib/url'; type TunnelState = | 'checking' @@ -358,26 +359,7 @@ export const TunnelSettings: React.FC = () => { return null; }, [localPort]); const openExternal = React.useCallback(async (url: string) => { - if (typeof window === 'undefined') { - return; - } - - type TauriShell = { shell?: { open?: (url: string) => Promise } }; - const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; - if (tauri?.shell?.open) { - try { - await tauri.shell.open(url); - return; - } catch { - // fall through - } - } - - try { - window.open(url, '_blank', 'noopener,noreferrer'); - } catch { - // ignore - } + await openExternalUrl(url); }, []); const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => { diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index c65b78cb..b6196a58 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -17,6 +17,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; +import { openExternalUrl } from '@/lib/url'; import type { ModelMetadata } from '@/types'; const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { @@ -406,7 +407,7 @@ export const ProvidersPage: React.FC = () => { })); if (urlCandidate) { - window.open(urlCandidate, '_blank', 'noopener,noreferrer'); + void openExternalUrl(urlCandidate); } setPendingOAuth({ providerId, methodIndex }); toast.message('Complete the OAuth flow in your browser'); @@ -721,7 +722,7 @@ export const ProvidersPage: React.FC = () => {
- +
@@ -914,7 +915,7 @@ export const ProvidersPage: React.FC = () => {
- +
diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 223ca3e1..36e27ec0 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -40,6 +40,7 @@ import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; +import { openExternalUrl } from '@/lib/url'; import { desktopSshLogsClear, desktopSshLogs, @@ -198,37 +199,6 @@ const formatLogLine = (line: string): string => { return `[${iso}] [${level}] ${message}`; }; -type TauriShell = { - shell?: { - open?: (url: string) => Promise; - }; -}; - -const openExternalUrl = async (url: string): Promise => { - const target = url.trim(); - if (!target || typeof window === 'undefined') { - return false; - } - - const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; - if (tauri?.shell?.open) { - const openedWithTauri = await tauri.shell - .open(target) - .then(() => true) - .catch(() => false); - if (openedWithTauri) { - return true; - } - } - - try { - window.open(target, '_blank', 'noopener,noreferrer'); - return true; - } catch { - return false; - } -}; - const navigateToUrl = (rawUrl: string): void => { const target = rawUrl.trim(); if (!target) { diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 0b5a844f..b2cafaad 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -20,6 +20,7 @@ import type { GroupSearchData, SessionGroup, SessionNode } from './types'; import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; +import { openExternalUrl } from '@/lib/url'; type DeleteFolderConfirm = { scopeKey: string; @@ -270,17 +271,10 @@ export function SessionGroupSection(props: Props): React.ReactNode { event.preventDefault(); event.stopPropagation(); const url = prIndicator?.url; - if (!url || typeof window === 'undefined') { + if (!url) { return; } - const tauri = (window as unknown as { __TAURI__?: { shell?: { open?: (target: string) => Promise } } }).__TAURI__; - if (tauri?.shell?.open) { - void tauri.shell.open(url).catch(() => { - window.open(url, '_blank', 'noopener,noreferrer'); - }); - return; - } - window.open(url, '_blank', 'noopener,noreferrer'); + void openExternalUrl(url); }; const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => { diff --git a/packages/ui/src/components/ui/UpdateDialog.tsx b/packages/ui/src/components/ui/UpdateDialog.tsx index 948756f6..78d7c581 100644 --- a/packages/ui/src/components/ui/UpdateDialog.tsx +++ b/packages/ui/src/components/ui/UpdateDialog.tsx @@ -10,6 +10,7 @@ import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiEx import { cn } from '@/lib/utils'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import { copyTextToClipboard } from '@/lib/clipboard'; +import { openExternalUrl } from '@/lib/url'; type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error'; @@ -230,25 +231,7 @@ export const UpdateDialog: React.FC = ({ }; const handleOpenExternal = useCallback(async (url: string) => { - if (typeof window === 'undefined') return; - - // Try Tauri backend - type TauriShell = { shell?: { open?: (url: string) => Promise } }; - const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; - if (tauri?.shell?.open) { - try { - await tauri.shell.open(url); - return; - } catch { - // fall through to window.open - } - } - - try { - window.open(url, '_blank', 'noopener,noreferrer'); - } catch { - // ignore - } + await openExternalUrl(url); }, []); const handleWebUpdate = useCallback(async () => { setWebUpdateState('updating'); diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 7796bd67..a3fd649b 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -45,6 +45,7 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible'; import { generatePullRequestDescription } from '@/lib/gitApi'; +import { openExternalUrl } from '@/lib/url'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -266,31 +267,7 @@ type ChatDispatchTarget = { const pullRequestDraftSnapshots = new Map(); -type TauriShell = { - shell?: { - open?: (url: string) => Promise; - }; -}; - -const openExternal = async (url: string) => { - if (typeof window === 'undefined') return; - - const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; - if (tauri?.shell?.open) { - try { - await tauri.shell.open(url); - return; - } catch { - // fall through - } - } - - try { - window.open(url, '_blank', 'noopener,noreferrer'); - } catch { - // ignore - } -}; +const openExternal = openExternalUrl; export const PullRequestSection: React.FC<{ directory: string; diff --git a/packages/ui/src/lib/url.ts b/packages/ui/src/lib/url.ts new file mode 100644 index 00000000..46ce97cd --- /dev/null +++ b/packages/ui/src/lib/url.ts @@ -0,0 +1,74 @@ +/** + * Utility for opening external URLs with Tauri shell support. + * In desktop runtime, uses tauri.shell.open() for proper system browser handling. + * Falls back to window.open() for web runtime. + */ + +type TauriShell = { + shell?: { + open?: (url: string) => Promise; + }; +}; + +const parseUrlSafely = (value: string): URL | null => { + try { + return new URL(value); + } catch { + return null; + } +}; + +export const isExternalHttpUrl = (url: string): boolean => { + const parsed = parseUrlSafely(url.trim()); + if (!parsed) { + return false; + } + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; +}; + +/** + * Opens an external URL in the system browser. + * In Tauri desktop runtime, uses tauri.shell.open() for proper handling. + * Falls back to window.open() for web runtime. + * + * @param url - The URL to open + * @returns Promise - true if the URL was opened successfully + */ +export const openExternalUrl = async (url: string): Promise => { + if (typeof window === 'undefined') { + return false; + } + + const target = url.trim(); + if (!target) { + return false; + } + + const parsed = parseUrlSafely(target); + if (!parsed) { + return false; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + const normalizedTarget = parsed.toString(); + + const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; + if (tauri?.shell?.open) { + try { + await tauri.shell.open(normalizedTarget); + return true; + } catch { + // Fall through to window.open + } + } + + try { + window.open(normalizedTarget, '_blank', 'noopener,noreferrer'); + return true; + } catch { + return false; + } +};