From fd9f76d9aad79ec4e18286a0bd849854818ca657 Mon Sep 17 00:00:00 2001 From: Jasper Date: Wed, 19 Aug 2026 16:54:21 +0000 Subject: [PATCH] fix(desktop): gate external URL protocols centrally (#11311) --- .../src/components/MarkdownContent.test.tsx | 16 +- ui/desktop/src/components/MarkdownContent.tsx | 136 ++++++----------- .../src/components/McpApps/McpAppRenderer.tsx | 49 +------ ui/desktop/src/i18n/messages/de.json | 30 ---- ui/desktop/src/i18n/messages/en.json | 30 ---- ui/desktop/src/i18n/messages/es.json | 30 ---- ui/desktop/src/i18n/messages/fr.json | 30 ---- ui/desktop/src/i18n/messages/hi.json | 30 ---- ui/desktop/src/i18n/messages/id.json | 30 ---- ui/desktop/src/i18n/messages/it.json | 30 ---- ui/desktop/src/i18n/messages/ja.json | 30 ---- ui/desktop/src/i18n/messages/ko.json | 30 ---- ui/desktop/src/i18n/messages/ms.json | 30 ---- ui/desktop/src/i18n/messages/pt.json | 30 ---- ui/desktop/src/i18n/messages/ru.json | 30 ---- ui/desktop/src/i18n/messages/tr.json | 30 ---- ui/desktop/src/i18n/messages/vi.json | 30 ---- ui/desktop/src/i18n/messages/zh-CN.json | 30 ---- ui/desktop/src/i18n/messages/zh-TW.json | 30 ---- ui/desktop/src/main.ts | 40 ++--- ui/desktop/src/preload.ts | 5 +- .../src/utils/externalLinkTranslations.ts | 138 ++++++++++++++++++ ui/desktop/src/utils/openExternalUrl.test.ts | 73 +++++++++ ui/desktop/src/utils/openExternalUrl.ts | 38 +++++ ui/desktop/src/utils/urlSecurity.ts | 2 + 25 files changed, 332 insertions(+), 645 deletions(-) create mode 100644 ui/desktop/src/utils/externalLinkTranslations.ts create mode 100644 ui/desktop/src/utils/openExternalUrl.test.ts create mode 100644 ui/desktop/src/utils/openExternalUrl.ts diff --git a/ui/desktop/src/components/MarkdownContent.test.tsx b/ui/desktop/src/components/MarkdownContent.test.tsx index 1798a0679..d8c9c36e9 100644 --- a/ui/desktop/src/components/MarkdownContent.test.tsx +++ b/ui/desktop/src/components/MarkdownContent.test.tsx @@ -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(); + + 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 | |------|-------| diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index 30163c49c..af6cd18ff 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -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 ( - <> -
- { - return ( - { - 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 }); - } - }} - /> - ); + > + - {processedContent} - -
- - + ], + ]} + components={{ + a: (props) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + if (!props.href) return; + + void handleOpenExternal(props.href); + }} + /> + ); + }, + code: MarkdownCode, + }} + > + {processedContent} + + ); }); diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index 769674b32..00a8e1c86 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -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] ); diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index 8bb591d93..ed5a93054 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index d2812a895..39acbd8c3 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index ebf27411a..c8bd5a44c 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 887690a6f..b3b9e742a 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 45fc60cb7..8339d175e 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -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": "चित्र-में-चित्र" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 30841c2c2..9b8a3094e 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index 01f052f28..2bb28af3e 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index be6ffeaa1..aaa2b1034 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -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": "ピクチャ・イン・ピクチャ" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index bee5f69d9..ed2e74ca0 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -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)" }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 8581c644e..42befa101 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index 0e885c3b1..15ffa4d4c 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 9fd0d7a43..56ab1dfaf 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -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": "Картинка в картинке" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 9887e3e2b..742807fce 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 3ab62edab..d58a569e3 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -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" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 9ae95cb87..c1f0ab54e 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -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": "画中画" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index 6a4583fa1..e93b9622a 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -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": "子母畫面" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index ac622eaf6..7739dfe28 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -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 () => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 50c442c9a..261bff10c 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -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> = { @@ -158,7 +159,7 @@ type ElectronAPI = { theme: string; tokensUpdated?: boolean; }) => void; - openExternal: (url: string) => Promise; + openExternal: (url: string) => Promise; // 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 => { + openExternal: (url: string): Promise => { return ipcRenderer.invoke('open-external', url); }, getVersion: (): string => { diff --git a/ui/desktop/src/utils/externalLinkTranslations.ts b/ui/desktop/src/utils/externalLinkTranslations.ts new file mode 100644 index 000000000..93b54ae8f --- /dev/null +++ b/ui/desktop/src/utils/externalLinkTranslations.ts @@ -0,0 +1,138 @@ +export type ExternalLinkLabels = { + title: string; + message: string; + detail: string; + open: string; + cancel: string; +}; + +const labelsByLocale: Record = { + 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)]; diff --git a/ui/desktop/src/utils/openExternalUrl.test.ts b/ui/desktop/src/utils/openExternalUrl.test.ts new file mode 100644 index 000000000..4ae7df6d1 --- /dev/null +++ b/ui/desktop/src/utils/openExternalUrl.test.ts @@ -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', + }) + ); + }); +}); diff --git a/ui/desktop/src/utils/openExternalUrl.ts b/ui/desktop/src/utils/openExternalUrl.ts new file mode 100644 index 000000000..40cddbe7f --- /dev/null +++ b/ui/desktop/src/utils/openExternalUrl.ts @@ -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 => { + 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'; +}; diff --git a/ui/desktop/src/utils/urlSecurity.ts b/ui/desktop/src/utils/urlSecurity.ts index 08fbb35f4..369c764d4 100644 --- a/ui/desktop/src/utils/urlSecurity.ts +++ b/ui/desktop/src/utils/urlSecurity.ts @@ -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.