fix(desktop): gate external URL protocols centrally (#11311)
This commit is contained in:
@@ -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 |
|
||||
|------|-------|
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "चित्र-में-चित्र"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "ピクチャ・イン・ピクチャ"
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Картинка в картинке"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "画中画"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user