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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a346d3f3da
commit
bf61ccedc7
@@ -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<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
|
||||
}
|
||||
await openExternalUrl(url);
|
||||
}, []);
|
||||
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
await openExternalUrl(url);
|
||||
}, []);
|
||||
|
||||
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 { 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 = () => {
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -914,7 +915,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<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 target = rawUrl.trim();
|
||||
if (!target) {
|
||||
|
||||
Reference in New Issue
Block a user