fix(desktop): gate external URL protocols centrally (#11311)

This commit is contained in:
Jasper
2026-08-19 16:54:21 +00:00
committed by GitHub
parent 0ede20cc5b
commit fd9f76d9aa
25 changed files with 332 additions and 645 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { render, type RenderOptions } from '@testing-library/react';
import { fireEvent, render, type RenderOptions } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/dom';
import MarkdownContent from './MarkdownContent';
import { IntlTestWrapper } from '../i18n/test-utils';
@@ -222,6 +222,20 @@ console.log('Hello, World!');
});
});
it('delegates unknown protocol confirmation to the main process once', async () => {
const openExternal = vi.fn().mockResolvedValue('cancelled');
window.electron.openExternal = openExternal;
renderWithIntl(<MarkdownContent content="[Open app](custom-handler:resource)" />);
fireEvent.click(await screen.findByRole('link', { name: 'Open app' }));
await waitFor(() => {
expect(openExternal).toHaveBeenCalledOnce();
expect(openExternal).toHaveBeenCalledWith('custom-handler:resource');
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders tables correctly', async () => {
const content = `| Name | Value |
|------|-------|
+46 -90
View File
@@ -28,8 +28,7 @@ const customOneDarkTheme = {
import { Check, Copy } from './icons';
import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity';
import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity';
import { ConfirmationModal } from './ui/ConfirmationModal';
import { BLOCKED_PROTOCOLS } from '../utils/urlSecurity';
import { defineMessages, useIntl } from '../i18n';
const i18n = defineMessages({
@@ -37,26 +36,6 @@ const i18n = defineMessages({
id: 'markdownContent.copyCode',
defaultMessage: 'Copy code',
},
openExternalLink: {
id: 'markdownContent.openExternalLink',
defaultMessage: 'Open External Link',
},
openProtocolLink: {
id: 'markdownContent.openProtocolLink',
defaultMessage: 'Open {protocol} link?',
},
thisWillOpen: {
id: 'markdownContent.thisWillOpen',
defaultMessage: 'This will open: {href}',
},
open: {
id: 'markdownContent.open',
defaultMessage: 'Open',
},
cancel: {
id: 'markdownContent.cancel',
defaultMessage: 'Cancel',
},
failedToOpenLink: {
id: 'markdownContent.failedToOpenLink',
defaultMessage: 'Failed to Open Link',
@@ -204,7 +183,6 @@ const MarkdownContent = memo(function MarkdownContent({
}: MarkdownContentProps) {
const intl = useIntl();
const [processedContent, setProcessedContent] = useState(content);
const [pendingLink, setPendingLink] = useState<{ protocol: string; href: string } | null>(null);
useEffect(() => {
try {
@@ -216,31 +194,26 @@ const MarkdownContent = memo(function MarkdownContent({
}
}, [content]);
const handleConfirmOpen = useCallback(async () => {
if (pendingLink) {
const handleOpenExternal = useCallback(
async (href: string) => {
try {
await window.electron.openExternal(pendingLink.href);
await window.electron.openExternal(href);
} catch {
await window.electron.showMessageBox({
type: 'error',
buttons: ['OK'],
title: intl.formatMessage(i18n.failedToOpenLink),
message: intl.formatMessage(i18n.noApplicationFound),
detail: pendingLink.href,
detail: href,
});
}
}
setPendingLink(null);
}, [pendingLink, intl]);
const handleCancelOpen = useCallback(() => {
setPendingLink(null);
}, []);
},
[intl]
);
return (
<>
<div
className={`w-full overflow-x-hidden prose prose-sm text-text-primary dark:prose-invert max-w-full word-break font-sans
<div
className={`w-full overflow-x-hidden prose prose-sm text-text-primary dark:prose-invert max-w-full word-break font-sans
prose-pre:p-0 prose-pre:m-0 !p-0
prose-code:break-all prose-code:whitespace-pre-wrap prose-code:font-mono
prose-a:break-all prose-a:overflow-wrap-anywhere
@@ -256,60 +229,43 @@ const MarkdownContent = memo(function MarkdownContent({
prose-ol:my-2 prose-ol:font-sans
prose-ul:mt-0 prose-ul:mb-3 prose-ul:font-sans
prose-li:m-0 prose-li:font-sans ${className}`}
>
<ReactMarkdown
urlTransform={customUrlTransform}
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[
rehypeKatex,
{
throwOnError: false,
errorColor: '#cc0000',
strict: false,
},
],
]}
components={{
a: (props) => {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!props.href) return;
if (isProtocolSafe(props.href)) {
window.electron.openExternal(props.href);
} else {
const protocol = getProtocol(props.href);
if (!protocol) return;
setPendingLink({ protocol, href: props.href });
}
}}
/>
);
>
<ReactMarkdown
urlTransform={customUrlTransform}
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[
rehypeKatex,
{
throwOnError: false,
errorColor: '#cc0000',
strict: false,
},
code: MarkdownCode,
}}
>
{processedContent}
</ReactMarkdown>
</div>
<ConfirmationModal
isOpen={pendingLink !== null}
title={intl.formatMessage(i18n.openExternalLink)}
message={intl.formatMessage(i18n.openProtocolLink, { protocol: pendingLink?.protocol ?? '' })}
detail={intl.formatMessage(i18n.thisWillOpen, { href: pendingLink?.href ?? '' })}
onConfirm={handleConfirmOpen}
onCancel={handleCancelOpen}
confirmLabel={intl.formatMessage(i18n.open)}
cancelLabel={intl.formatMessage(i18n.cancel)}
/>
</>
],
]}
components={{
a: (props) => {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!props.href) return;
void handleOpenExternal(props.href);
}}
/>
);
},
code: MarkdownCode,
}}
>
{processedContent}
</ReactMarkdown>
</div>
);
});
@@ -40,7 +40,6 @@ import { AppEvents } from '../../constants/events';
import { useTheme } from '../../contexts/ThemeContext';
import { cn } from '../../utils';
import { errorMessage } from '../../utils/conversionUtils';
import { getProtocol, isProtocolSafe } from '../../utils/urlSecurity';
import { defineMessages, useIntl } from '../../i18n';
import FlyingBird from '../FlyingBird';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
@@ -99,26 +98,6 @@ const i18n = defineMessages({
id: 'mcpAppRenderer.invalidUrl',
defaultMessage: 'Invalid URL',
},
openExternalLinkTitle: {
id: 'mcpAppRenderer.openExternalLinkTitle',
defaultMessage: 'Open External Link',
},
openProtocolLink: {
id: 'mcpAppRenderer.openProtocolLink',
defaultMessage: 'Open {protocol} link?',
},
openLinkDetail: {
id: 'mcpAppRenderer.openLinkDetail',
defaultMessage: 'This will open: {url}',
},
cancelButton: {
id: 'mcpAppRenderer.cancelButton',
defaultMessage: 'Cancel',
},
openButton: {
id: 'mcpAppRenderer.openButton',
defaultMessage: 'Open',
},
failedToLoadResource: {
id: 'mcpAppRenderer.failedToLoadResource',
defaultMessage: 'Failed to load resource',
@@ -799,31 +778,15 @@ export default function McpAppRenderer({
const handleOpenLink = useCallback(
async ({ url }: { url: string }) => {
if (isProtocolSafe(url)) {
await window.electron.openExternal(url);
const result = await window.electron.openExternal(url);
if (result === 'opened') {
return { status: 'success' as const };
}
const protocol = getProtocol(url);
if (!protocol) {
return { status: 'error' as const, message: intl.formatMessage(i18n.invalidUrl) };
}
const result = await window.electron.showMessageBox({
type: 'question',
buttons: [intl.formatMessage(i18n.cancelButton), intl.formatMessage(i18n.openButton)],
defaultId: 0,
title: intl.formatMessage(i18n.openExternalLinkTitle),
message: intl.formatMessage(i18n.openProtocolLink, { protocol }),
detail: intl.formatMessage(i18n.openLinkDetail, { url }),
});
if (result.response !== 1) {
return { status: 'error' as const, message: 'User cancelled' };
}
await window.electron.openExternal(url);
return { status: 'success' as const };
return {
status: 'error' as const,
message: result === 'cancelled' ? 'User cancelled' : intl.formatMessage(i18n.invalidUrl),
};
},
[intl]
);
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "{modelId} verwenden"
},
"markdownContent.cancel": {
"defaultMessage": "Abbrechen"
},
"markdownContent.copyCode": {
"defaultMessage": "Code kopieren"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Keine Anwendung zum Öffnen dieses Links gefunden."
},
"markdownContent.open": {
"defaultMessage": "Öffnen"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Externen Link öffnen"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "{protocol}-Link öffnen?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Dies öffnet: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "App"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Abbrechen"
},
"mcpAppRenderer.close": {
"defaultMessage": "Schließen"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Bild-in-Bild-Fenster verschieben (Pfeiltasten verwenden)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Öffnen"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Externen Link öffnen"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Dies öffnet: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "{protocol}-Link öffnen?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Bild-in-Bild"
},
-30
View File
@@ -1922,9 +1922,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Use {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Cancel"
},
"markdownContent.copyCode": {
"defaultMessage": "Copy code"
},
@@ -1934,24 +1931,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "No application found to open this link."
},
"markdownContent.open": {
"defaultMessage": "Open"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Open External Link"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Open {protocol} link?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "This will open: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "App"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Cancel"
},
"mcpAppRenderer.close": {
"defaultMessage": "Close"
},
@@ -1976,18 +1958,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Move Picture-in-Picture window (use arrow keys)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Open"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Open External Link"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "This will open: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Open {protocol} link?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Picture-in-Picture"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Usar {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Cancelar"
},
"markdownContent.copyCode": {
"defaultMessage": "Copiar código"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "No se encontró ninguna aplicación para abrir este enlace."
},
"markdownContent.open": {
"defaultMessage": "Abrir"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Abrir enlace externo"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "¿Abrir enlace {protocol}?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Esto abrirá: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "App"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Cancelar"
},
"mcpAppRenderer.close": {
"defaultMessage": "Cerrar"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Mover la ventana de imagen en imagen (usa las teclas de flecha)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Abrir"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Abrir enlace externo"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Esto abrirá: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "¿Abrir enlace {protocol}?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Imagen en imagen"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Utiliser {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Annuler"
},
"markdownContent.copyCode": {
"defaultMessage": "Copier le code"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Aucune application trouvée pour ouvrir ce lien."
},
"markdownContent.open": {
"defaultMessage": "Ouvrir"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Ouvrir le lien externe"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Ouvrir le lien {protocol} ?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Cela ouvrira : {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "Application"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Annuler"
},
"mcpAppRenderer.close": {
"defaultMessage": "Fermer"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Déplacer la fenêtre Picture-in-Picture (utilisez les touches fléchées)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Ouvrir"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Ouvrir le lien externe"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Cela ouvrira : {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Ouvrir le lien {protocol} ?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Picture-in-Picture"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "{modelId} का प्रयोग करें"
},
"markdownContent.cancel": {
"defaultMessage": "रद्द करें"
},
"markdownContent.copyCode": {
"defaultMessage": "कोड कॉपी करें"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "इस लिंक को खोलने के लिए कोई एप्लिकेशन नहीं मिला."
},
"markdownContent.open": {
"defaultMessage": "खुला"
},
"markdownContent.openExternalLink": {
"defaultMessage": "बाहरी लिंक खोलें"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "{protocol} लिंक खोलें?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "यह खुलेगा: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "ऐप"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "रद्द करें"
},
"mcpAppRenderer.close": {
"defaultMessage": "बंद करें"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "पिक्चर-इन-पिक्चर विंडो को स्थानांतरित करें (तीर कुंजियों का उपयोग करें)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "खुला"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "बाहरी लिंक खोलें"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "यह खुलेगा: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "{protocol} लिंक खोलें?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "चित्र-में-चित्र"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Gunakan {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Batal"
},
"markdownContent.copyCode": {
"defaultMessage": "Salin kode"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Tidak ada aplikasi yang ditemukan untuk membuka tautan ini."
},
"markdownContent.open": {
"defaultMessage": "Buka"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Buka Tautan Eksternal"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Buka tautan {protocol}?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Ini akan membuka: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "Aplikasi"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Batal"
},
"mcpAppRenderer.close": {
"defaultMessage": "Tutup"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Pindahkan jendela Picture-in-Picture (gunakan tombol panah)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Buka"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Buka Tautan Eksternal"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Ini akan membuka: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Buka tautan {protocol}?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Picture-in-Picture"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Usa {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Annulla"
},
"markdownContent.copyCode": {
"defaultMessage": "Copia codice"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Nessuna applicazione trovata per aprire questo link."
},
"markdownContent.open": {
"defaultMessage": "Apri"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Apri link esterno"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Aprire il link {protocol}?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Questo aprirà: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "App"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Annulla"
},
"mcpAppRenderer.close": {
"defaultMessage": "Chiudi"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Sposta la finestra Picture-in-Picture (usa i tasti freccia)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Apri"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Apri link esterno"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Questo aprirà: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Aprire il link {protocol}?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Picture-in-Picture"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "{modelId}を使用"
},
"markdownContent.cancel": {
"defaultMessage": "キャンセル"
},
"markdownContent.copyCode": {
"defaultMessage": "コードをコピー"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "このリンクを開くアプリケーションが見つかりません。"
},
"markdownContent.open": {
"defaultMessage": "開く"
},
"markdownContent.openExternalLink": {
"defaultMessage": "外部リンクを開く"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "{protocol}リンクを開きますか?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "次を開きます: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "アプリ"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "キャンセル"
},
"mcpAppRenderer.close": {
"defaultMessage": "閉じる"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "ピクチャ・イン・ピクチャウィンドウを移動(矢印キーを使用)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "開く"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "外部リンクを開く"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "次を開きます: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "{protocol}リンクを開きますか?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "ピクチャ・イン・ピクチャ"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "{modelId} 사용"
},
"markdownContent.cancel": {
"defaultMessage": "취소"
},
"markdownContent.copyCode": {
"defaultMessage": "코드 복사"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "이 링크를 열 수 있는 애플리케이션을 찾을 수 없습니다."
},
"markdownContent.open": {
"defaultMessage": "열기"
},
"markdownContent.openExternalLink": {
"defaultMessage": "외부 링크 열기"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "{protocol} 링크를 열까요?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "열립니다: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "앱"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "취소"
},
"mcpAppRenderer.close": {
"defaultMessage": "닫기"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Picture-in-Picture 창 이동(화살표 키 사용)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "열기"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "외부 링크 열기"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "열립니다: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "{protocol} 링크를 열까요?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "PIP(Picture-in-Picture)"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Guna {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Batal"
},
"markdownContent.copyCode": {
"defaultMessage": "Salin kod"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Tiada aplikasi dijumpai untuk membuka pautan ini."
},
"markdownContent.open": {
"defaultMessage": "Buka"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Buka Pautan Luaran"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Buka pautan {protocol}?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Ini akan membuka: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "Apl"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Batal"
},
"mcpAppRenderer.close": {
"defaultMessage": "Tutup"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Alihkan tetingkap Gambar-dalam-Gambar (gunakan kekunci anak panah)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Buka"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Buka Pautan Luaran"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Ini akan membuka: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Buka pautan {protocol}?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Gambar-dalam-Gambar"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Usar {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Cancelar"
},
"markdownContent.copyCode": {
"defaultMessage": "Copiar código"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Nenhum aplicativo encontrado para abrir este link."
},
"markdownContent.open": {
"defaultMessage": "Abrir"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Abrir Link Externo"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Abrir link {protocol}?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Isto abrirá: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "Aplicativo"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Cancelar"
},
"mcpAppRenderer.close": {
"defaultMessage": "Fechar"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Mover a janela Picture-in-Picture (use as teclas de seta)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Abrir"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Abrir Link Externo"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Isto abrirá: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Abrir link {protocol}?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Picture-in-Picture"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Использовать {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Отмена"
},
"markdownContent.copyCode": {
"defaultMessage": "Копировать код"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Не найдено приложение для открытия этой ссылки."
},
"markdownContent.open": {
"defaultMessage": "Открыть"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Открыть внешнюю ссылку"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Открыть ссылку {protocol}?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Будет открыто: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "Приложение"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Отмена"
},
"mcpAppRenderer.close": {
"defaultMessage": "Закрыть"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Переместить окно «картинка в картинке» (используйте стрелки)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Открыть"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Открыть внешнюю ссылку"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Будет открыто: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Открыть ссылку {protocol}?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Картинка в картинке"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "{modelId}'yi kullanın"
},
"markdownContent.cancel": {
"defaultMessage": "İptal"
},
"markdownContent.copyCode": {
"defaultMessage": "Kodu kopyala"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Bu bağlantıyı açacak uygulama bulunamadı."
},
"markdownContent.open": {
"defaultMessage": "Açık"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Dış Bağlantıyı Aç"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "{protocol} bağlantısı açılsın mı?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Bu açılacak: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "Uygulama"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "İptal"
},
"mcpAppRenderer.close": {
"defaultMessage": "Kapat"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Resim İçinde Resim penceresini taşıyın (ok tuşlarını kullanın)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Açık"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Dış Bağlantıyı Aç"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Bu açılacak: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "{protocol} bağlantısı açılsın mı?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Resim İçinde Resim"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "Sử dụng {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "Hủy"
},
"markdownContent.copyCode": {
"defaultMessage": "Sao chép mã"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "Không tìm thấy ứng dụng nào để mở liên kết này."
},
"markdownContent.open": {
"defaultMessage": "Mở"
},
"markdownContent.openExternalLink": {
"defaultMessage": "Mở liên kết bên ngoài"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "Mở liên kết {protocol}?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "Thao tác này sẽ mở: {href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "Ứng dụng"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "Hủy"
},
"mcpAppRenderer.close": {
"defaultMessage": "Đóng"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "Di chuyển cửa sổ Hình-trong-hình (dùng phím mũi tên)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "Mở"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "Mở liên kết bên ngoài"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "Thao tác này sẽ mở: {url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "Mở liên kết {protocol}?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "Hình-trong-hình"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "使用 {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "取消"
},
"markdownContent.copyCode": {
"defaultMessage": "复制代码"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "未找到可以打开此链接的应用。"
},
"markdownContent.open": {
"defaultMessage": "打开"
},
"markdownContent.openExternalLink": {
"defaultMessage": "打开外部链接"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "打开 {protocol} 链接?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "这将打开:{href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "应用"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "取消"
},
"mcpAppRenderer.close": {
"defaultMessage": "关闭"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "移动画中画窗口(使用方向键)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "打开"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "打开外部链接"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "这将打开:{url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "打开 {protocol} 链接?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "画中画"
},
-30
View File
@@ -1901,9 +1901,6 @@
"localModelPicker.useModel": {
"defaultMessage": "使用 {modelId}"
},
"markdownContent.cancel": {
"defaultMessage": "取消"
},
"markdownContent.copyCode": {
"defaultMessage": "複製程式碼"
},
@@ -1913,24 +1910,9 @@
"markdownContent.noApplicationFound": {
"defaultMessage": "找不到可開啟此連結的應用程式。"
},
"markdownContent.open": {
"defaultMessage": "開啟"
},
"markdownContent.openExternalLink": {
"defaultMessage": "開啟外部連結"
},
"markdownContent.openProtocolLink": {
"defaultMessage": "要開啟 {protocol} 連結嗎?"
},
"markdownContent.thisWillOpen": {
"defaultMessage": "這會開啟:{href}"
},
"mcpAppRenderer.appFallbackTitle": {
"defaultMessage": "應用程式"
},
"mcpAppRenderer.cancelButton": {
"defaultMessage": "取消"
},
"mcpAppRenderer.close": {
"defaultMessage": "關閉"
},
@@ -1955,18 +1937,6 @@
"mcpAppRenderer.movePipWindow": {
"defaultMessage": "移動子母畫面視窗(使用方向鍵)"
},
"mcpAppRenderer.openButton": {
"defaultMessage": "開啟"
},
"mcpAppRenderer.openExternalLinkTitle": {
"defaultMessage": "開啟外部連結"
},
"mcpAppRenderer.openLinkDetail": {
"defaultMessage": "這會開啟:{url}"
},
"mcpAppRenderer.openProtocolLink": {
"defaultMessage": "要開啟 {protocol} 連結嗎?"
},
"mcpAppRenderer.pictureInPicture": {
"defaultMessage": "子母畫面"
},
+11 -29
View File
@@ -55,7 +55,8 @@ import './utils/gitBranchIpc';
import './utils/recipeHash';
import type { GooseApp } from './types/apps';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
import { WEB_PROTOCOLS } from './utils/urlSecurity';
import { openExternalUrl } from './utils/openExternalUrl';
import { buildCSP } from './utils/csp';
import { resolveWorkingDir } from './utils/workingDir';
import {
@@ -1415,16 +1416,9 @@ const createChat = async (
// Handle new window creation for links (fallback for any links not handled by onClick)
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const protocol = new URL(url).protocol;
if (BLOCKED_PROTOCOLS.includes(protocol)) {
return { action: 'deny' };
}
} catch {
return { action: 'deny' };
}
shell.openExternal(url);
void openExternalUrl(url, mainWindow, getConfiguredGooseLocale()).catch((error) => {
log.error('Failed to open external URL:', error);
});
return { action: 'deny' };
});
@@ -1433,15 +1427,9 @@ const createChat = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mainWindow.webContents.on('new-window' as any, function (event: any, url: string) {
event.preventDefault();
try {
const protocol = new URL(url).protocol;
if (BLOCKED_PROTOCOLS.includes(protocol)) {
return;
}
} catch {
return;
}
shell.openExternal(url);
void openExternalUrl(url, mainWindow, getConfiguredGooseLocale()).catch((error) => {
log.error('Failed to open external URL:', error);
});
});
const windowId = mainWindow.id;
@@ -1943,15 +1931,9 @@ ipcMain.on('react-ready', (event) => {
}
});
ipcMain.handle('open-external', async (_event, url: string) => {
const parsedUrl = new URL(url);
if (BLOCKED_PROTOCOLS.includes(parsedUrl.protocol)) {
console.warn(`[Main] Blocked dangerous protocol: ${parsedUrl.protocol}`);
return;
}
await shell.openExternal(url);
ipcMain.handle('open-external', async (event, url: string) => {
const senderWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined;
return openExternalUrl(url, senderWindow, getConfiguredGooseLocale());
});
ipcMain.handle('directory-chooser', async () => {
+3 -2
View File
@@ -3,6 +3,7 @@ import { Recipe } from './recipe';
import type { GooseApp } from './types/apps';
import type { Settings, SettingKey } from './utils/settings';
import { defaultSettings } from './utils/settings';
import type { OpenExternalUrlResult } from './utils/urlSecurity';
// Mapping from settings keys to their old localStorage keys for lazy migration
const localStorageKeyMap: Partial<Record<SettingKey, string>> = {
@@ -158,7 +159,7 @@ type ElectronAPI = {
theme: string;
tokensUpdated?: boolean;
}) => void;
openExternal: (url: string) => Promise<void>;
openExternal: (url: string) => Promise<OpenExternalUrlResult>;
// Update-related functions
getVersion: () => string;
checkForUpdates: () => Promise<{ updateInfo: unknown; error: string | null }>;
@@ -300,7 +301,7 @@ const electronAPI: ElectronAPI = {
}) => {
ipcRenderer.send('broadcast-theme-change', themeData);
},
openExternal: (url: string): Promise<void> => {
openExternal: (url: string): Promise<OpenExternalUrlResult> => {
return ipcRenderer.invoke('open-external', url);
},
getVersion: (): string => {
@@ -0,0 +1,138 @@
export type ExternalLinkLabels = {
title: string;
message: string;
detail: string;
open: string;
cancel: string;
};
const labelsByLocale: Record<string, ExternalLinkLabels> = {
en: {
title: 'Open External Link',
message: 'Open {protocol} link?',
detail: 'This will open: {href}',
open: 'Open',
cancel: 'Cancel',
},
de: {
title: 'Externen Link öffnen',
message: '{protocol}-Link öffnen?',
detail: 'Dies öffnet: {href}',
open: 'Öffnen',
cancel: 'Abbrechen',
},
es: {
title: 'Abrir enlace externo',
message: '¿Abrir enlace {protocol}?',
detail: 'Esto abrirá: {href}',
open: 'Abrir',
cancel: 'Cancelar',
},
fr: {
title: 'Ouvrir le lien externe',
message: 'Ouvrir le lien {protocol} ?',
detail: 'Cela ouvrira : {href}',
open: 'Ouvrir',
cancel: 'Annuler',
},
hi: {
title: 'बाहरी लिंक खोलें',
message: '{protocol} लिंक खोलें?',
detail: 'यह खुलेगा: {href}',
open: 'खुला',
cancel: 'रद्द करें',
},
id: {
title: 'Buka Tautan Eksternal',
message: 'Buka tautan {protocol}?',
detail: 'Ini akan membuka: {href}',
open: 'Buka',
cancel: 'Batal',
},
it: {
title: 'Apri link esterno',
message: 'Aprire il link {protocol}?',
detail: 'Questo aprirà: {href}',
open: 'Apri',
cancel: 'Annulla',
},
ja: {
title: '外部リンクを開く',
message: '{protocol}リンクを開きますか?',
detail: '次を開きます: {href}',
open: '開く',
cancel: 'キャンセル',
},
ko: {
title: '외부 링크 열기',
message: '{protocol} 링크를 열까요?',
detail: '열립니다: {href}',
open: '열기',
cancel: '취소',
},
ms: {
title: 'Buka Pautan Luaran',
message: 'Buka pautan {protocol}?',
detail: 'Ini akan membuka: {href}',
open: 'Buka',
cancel: 'Batal',
},
pt: {
title: 'Abrir Link Externo',
message: 'Abrir link {protocol}?',
detail: 'Isto abrirá: {href}',
open: 'Abrir',
cancel: 'Cancelar',
},
ru: {
title: 'Открыть внешнюю ссылку',
message: 'Открыть ссылку {protocol}?',
detail: 'Будет открыто: {href}',
open: 'Открыть',
cancel: 'Отмена',
},
tr: {
title: 'Dış Bağlantıyı Aç',
message: '{protocol} bağlantısı açılsın mı?',
detail: 'Bu açılacak: {href}',
open: 'Açık',
cancel: 'İptal',
},
vi: {
title: 'Mở liên kết bên ngoài',
message: 'Mở liên kết {protocol}?',
detail: 'Thao tác này sẽ mở: {href}',
open: 'Mở',
cancel: 'Hủy',
},
'zh-CN': {
title: '打开外部链接',
message: '打开 {protocol} 链接?',
detail: '这将打开:{href}',
open: '打开',
cancel: '取消',
},
'zh-TW': {
title: '開啟外部連結',
message: '要開啟 {protocol} 連結嗎?',
detail: '這會開啟:{href}',
open: '開啟',
cancel: '取消',
},
};
const selectLocale = (locale?: string): string => {
const normalized = locale?.replace(/_/g, '-') ?? 'en';
if (labelsByLocale[normalized]) return normalized;
const lower = normalized.toLowerCase();
if (lower === 'zh' || lower.startsWith('zh-')) {
return /^zh-(hant|tw|hk|mo)\b/.test(lower) ? 'zh-TW' : 'zh-CN';
}
const language = lower.split('-')[0];
return labelsByLocale[language] ? language : 'en';
};
export const getExternalLinkLabels = (locale?: string): ExternalLinkLabels =>
labelsByLocale[selectLocale(locale)];
@@ -0,0 +1,73 @@
import { dialog, shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { openExternalUrl } from './openExternalUrl';
vi.mock('electron', () => ({
dialog: { showMessageBox: vi.fn() },
shell: { openExternal: vi.fn() },
}));
describe('openExternalUrl', () => {
beforeEach(() => {
vi.mocked(dialog.showMessageBox).mockReset();
vi.mocked(shell.openExternal).mockReset();
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
});
it('opens allowlisted protocols without confirmation', async () => {
await expect(openExternalUrl('https://example.com/docs')).resolves.toBe('opened');
expect(dialog.showMessageBox).not.toHaveBeenCalled();
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com/docs');
});
it.each(['file:///tmp/secret', 'javascript:alert(1)', 'not a URL'])(
'blocks dangerous or malformed URL %s',
async (url) => {
await expect(openExternalUrl(url)).resolves.toBe('blocked');
expect(dialog.showMessageBox).not.toHaveBeenCalled();
expect(shell.openExternal).not.toHaveBeenCalled();
}
);
it('does not open an unknown protocol when confirmation is cancelled', async () => {
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false });
await expect(openExternalUrl('ms-msdt:exploit')).resolves.toBe('cancelled');
expect(dialog.showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
defaultId: 0,
cancelId: 0,
title: 'Open External Link',
message: 'Open ms-msdt: link?',
detail: 'This will open: ms-msdt:exploit',
})
);
expect(shell.openExternal).not.toHaveBeenCalled();
});
it('opens an unknown protocol only after explicit confirmation', async () => {
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false });
await expect(openExternalUrl('custom-handler:resource')).resolves.toBe('opened');
expect(shell.openExternal).toHaveBeenCalledWith('custom-handler:resource');
});
it('uses the configured locale for an unknown-protocol confirmation', async () => {
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false });
await openExternalUrl('custom-handler:resource', undefined, 'es-ES');
expect(dialog.showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
buttons: ['Cancelar', 'Abrir'],
title: 'Abrir enlace externo',
message: '¿Abrir enlace custom-handler:?',
detail: 'Esto abrirá: custom-handler:resource',
})
);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { dialog, shell, type BrowserWindow, type MessageBoxOptions } from 'electron';
import { getExternalLinkLabels } from './externalLinkTranslations';
import { BLOCKED_PROTOCOLS, SAFE_PROTOCOLS, type OpenExternalUrlResult } from './urlSecurity';
export const openExternalUrl = async (
url: string,
parentWindow?: BrowserWindow,
locale?: string
): Promise<OpenExternalUrlResult> => {
let protocol: string;
try {
protocol = new URL(url).protocol;
} catch {
return 'blocked';
}
if (BLOCKED_PROTOCOLS.includes(protocol)) return 'blocked';
if (!SAFE_PROTOCOLS.includes(protocol)) {
const labels = getExternalLinkLabels(locale);
const options: MessageBoxOptions = {
type: 'warning',
buttons: [labels.cancel, labels.open],
defaultId: 0,
cancelId: 0,
title: labels.title,
message: labels.message.replace('{protocol}', protocol),
detail: labels.detail.replace('{href}', url),
};
const result = parentWindow
? await dialog.showMessageBox(parentWindow, options)
: await dialog.showMessageBox(options);
if (result.response !== 1) return 'cancelled';
}
await shell.openExternal(url);
return 'opened';
};
+2
View File
@@ -66,6 +66,8 @@ export const SAFE_PROTOCOLS = [
'goose:',
];
export type OpenExternalUrlResult = 'opened' | 'blocked' | 'cancelled';
/**
* Check if a URL uses a protocol that is safe to open without user confirmation.
* Dangerous protocols are blocked centrally in main.ts open-external handler.