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; + } +};