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 <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-03-20 13:11:45 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a346d3f3da
commit bf61ccedc7
11 changed files with 158 additions and 151 deletions
+1 -1
View File
@@ -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){}"); init_script.push_str("\ntry{var old=document.getElementById('__oc-instance-switcher');if(old)old.remove();}catch(_e){}");
if !cfg!(debug_assertions) { 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 init_script
@@ -11,6 +11,7 @@ import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry'; import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes'; import { getDefaultTheme } from '@/lib/theme/themes';
@@ -152,6 +153,59 @@ const useCurrentMermaidTheme = () => {
: fallbackLight); : fallbackLight);
}; };
const useExternalLinkInteractions = ({
containerRef,
enabled,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
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 // Table utility functions
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => { const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = []; const headers: string[] = [];
@@ -1356,6 +1410,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode, preferRuntimeEditor: runtime.isVSCode,
deferValidationUntilIdle: isStreaming, deferValidationUntilIdle: isStreaming,
}); });
useExternalLinkInteractions({ containerRef: streamdownContainerRef });
const shikiThemes = useMarkdownShikiThemes(); const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes); const streamdownPlugins = useStreamdownPlugins(shikiThemes);
@@ -1377,12 +1432,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
mode={isStreaming && !disableStreamAnimation ? 'streaming' : 'static'} mode={isStreaming && !disableStreamAnimation ? 'streaming' : 'static'}
shikiTheme={shikiThemes} shikiTheme={shikiThemes}
className={streamdownClassName} className={streamdownClassName}
controls={streamdownControls} controls={streamdownControls}
plugins={streamdownPlugins} plugins={streamdownPlugins}
components={streamdownComponents} components={streamdownComponents}
animated={disableStreamAnimation ? undefined : streamdownAnimated} animated={disableStreamAnimation ? undefined : streamdownAnimated}
isAnimating={disableStreamAnimation ? false : isStreaming} isAnimating={disableStreamAnimation ? false : isStreaming}
> // @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={{ enabled: false }}
>
{content} {content}
</Streamdown> </Streamdown>
</div> </div>
@@ -1438,6 +1495,7 @@ export const SimpleMarkdownRenderer: React.FC<{
editor, editor,
preferRuntimeEditor: runtime.isVSCode, preferRuntimeEditor: runtime.isVSCode,
}); });
useExternalLinkInteractions({ containerRef: streamdownContainerRef, enabled: !disableLinkSafety });
const shikiThemes = useMarkdownShikiThemes(); const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes); const streamdownPlugins = useStreamdownPlugins(shikiThemes);
@@ -1460,7 +1518,7 @@ export const SimpleMarkdownRenderer: React.FC<{
plugins={streamdownPlugins} plugins={streamdownPlugins}
components={streamdownComponents} components={streamdownComponents}
// @ts-expect-error Streamdown type missing linkSafety in older minor // @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={disableLinkSafety ? { enabled: false } : undefined} linkSafety={{ enabled: false }}
> >
{renderedContent} {renderedContent}
</Streamdown> </Streamdown>
@@ -21,6 +21,7 @@ import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore'; import { useTerminalStore } from '@/stores/useTerminalStore';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
import { import {
getProjectActionsState, getProjectActionsState,
type OpenChamberProjectAction, type OpenChamberProjectAction,
@@ -225,22 +226,7 @@ export const ProjectActionsButton = ({
}, [isDesktopShellApp, loadDesktopSsh]); }, [isDesktopShellApp, loadDesktopSsh]);
const openExternal = React.useCallback(async (url: string) => { const openExternal = React.useCallback(async (url: string) => {
try { await openExternalUrl(url);
const tauri = (window as unknown as {
__TAURI__?: {
shell?: {
open?: (target: string) => Promise<unknown>;
};
};
}).__TAURI__;
if (tauri?.shell?.open) {
await tauri.shell.open(url);
return;
}
} catch {
// noop
}
window.open(url, '_blank', 'noopener,noreferrer');
}, []); }, []);
const loadActions = React.useCallback(async () => { const loadActions = React.useCallback(async () => {
@@ -6,6 +6,7 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { GitHubAuthStatus } from '@/lib/api/types'; import type { GitHubAuthStatus } from '@/lib/api/types';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { RiGithubFill, RiInformationLine } from '@remixicon/react'; import { RiGithubFill, RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -41,26 +42,7 @@ export const GitHubSettings: React.FC = () => {
const setStatus = useGitHubAuthStore((state) => state.setStatus); const setStatus = useGitHubAuthStore((state) => state.setStatus);
const openExternal = React.useCallback(async (url: string) => { const openExternal = React.useCallback(async (url: string) => {
if (typeof window === 'undefined') { await openExternalUrl(url);
return;
}
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
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 [isBusy, setIsBusy] = React.useState(false); const [isBusy, setIsBusy] = React.useState(false);
@@ -27,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { requestFileAccess } from '@/lib/desktop'; import { requestFileAccess } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
type TunnelState = type TunnelState =
| 'checking' | 'checking'
@@ -358,26 +359,7 @@ export const TunnelSettings: React.FC = () => {
return null; return null;
}, [localPort]); }, [localPort]);
const openExternal = React.useCallback(async (url: string) => { const openExternal = React.useCallback(async (url: string) => {
if (typeof window === 'undefined') { await openExternalUrl(url);
return;
}
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
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 checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => { const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
@@ -17,6 +17,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import type { ModelMetadata } from '@/types'; import type { ModelMetadata } from '@/types';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
@@ -406,7 +407,7 @@ export const ProvidersPage: React.FC = () => {
})); }));
if (urlCandidate) { if (urlCandidate) {
window.open(urlCandidate, '_blank', 'noopener,noreferrer'); void openExternalUrl(urlCandidate);
} }
setPendingOAuth({ providerId, methodIndex }); setPendingOAuth({ providerId, methodIndex });
toast.message('Complete the OAuth flow in your browser'); toast.message('Complete the OAuth flow in your browser');
@@ -721,7 +722,7 @@ export const ProvidersPage: React.FC = () => {
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" /> <Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</Button> <Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>Open</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button> <Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
</div> </div>
</div> </div>
@@ -914,7 +915,7 @@ export const ProvidersPage: React.FC = () => {
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" /> <Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</Button> <Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>Open</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button> <Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
</div> </div>
</div> </div>
@@ -40,6 +40,7 @@ import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { import {
desktopSshLogsClear, desktopSshLogsClear,
desktopSshLogs, desktopSshLogs,
@@ -198,37 +199,6 @@ const formatLogLine = (line: string): string => {
return `[${iso}] [${level}] ${message}`; return `[${iso}] [${level}] ${message}`;
}; };
type TauriShell = {
shell?: {
open?: (url: string) => Promise<unknown>;
};
};
const openExternalUrl = async (url: string): Promise<boolean> => {
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 navigateToUrl = (rawUrl: string): void => {
const target = rawUrl.trim(); const target = rawUrl.trim();
if (!target) { if (!target) {
@@ -20,6 +20,7 @@ import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { openExternalUrl } from '@/lib/url';
type DeleteFolderConfirm = { type DeleteFolderConfirm = {
scopeKey: string; scopeKey: string;
@@ -270,17 +271,10 @@ export function SessionGroupSection(props: Props): React.ReactNode {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const url = prIndicator?.url; const url = prIndicator?.url;
if (!url || typeof window === 'undefined') { if (!url) {
return; return;
} }
const tauri = (window as unknown as { __TAURI__?: { shell?: { open?: (target: string) => Promise<unknown> } } }).__TAURI__; void openExternalUrl(url);
if (tauri?.shell?.open) {
void tauri.shell.open(url).catch(() => {
window.open(url, '_blank', 'noopener,noreferrer');
});
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}; };
const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => { const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => {
+2 -19
View File
@@ -10,6 +10,7 @@ import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiEx
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error'; type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
@@ -230,25 +231,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
}; };
const handleOpenExternal = useCallback(async (url: string) => { const handleOpenExternal = useCallback(async (url: string) => {
if (typeof window === 'undefined') return; await openExternalUrl(url);
// Try Tauri backend
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
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
}
}, []); }, []);
const handleWebUpdate = useCallback(async () => { const handleWebUpdate = useCallback(async () => {
setWebUpdateState('updating'); setWebUpdateState('updating');
@@ -45,6 +45,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import { generatePullRequestDescription } from '@/lib/gitApi'; import { generatePullRequestDescription } from '@/lib/gitApi';
import { openExternalUrl } from '@/lib/url';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
@@ -266,31 +267,7 @@ type ChatDispatchTarget = {
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>(); const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
type TauriShell = { const openExternal = openExternalUrl;
shell?: {
open?: (url: string) => Promise<unknown>;
};
};
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
}
};
export const PullRequestSection: React.FC<{ export const PullRequestSection: React.FC<{
directory: string; directory: string;
+74
View File
@@ -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<unknown>;
};
};
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<boolean> - true if the URL was opened successfully
*/
export const openExternalUrl = async (url: string): Promise<boolean> => {
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;
}
};