2026-03-20 19:22:43 +02:00
|
|
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
type DesktopBridgeGlobal = {
|
|
|
|
|
openExternal?: (url: string) => Promise<unknown>;
|
2026-03-20 19:11:45 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseUrlSafely = (value: string): URL | null => {
|
|
|
|
|
try {
|
|
|
|
|
return new URL(value);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const isExternalHttpUrl = (url: string): boolean => {
|
|
|
|
|
const parsed = parseUrlSafely(url.trim());
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 19:25:10 +03:00
|
|
|
export const getExternalFaviconUrl = (url: string): string | null => {
|
|
|
|
|
const parsed = parseUrlSafely(url.trim());
|
|
|
|
|
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `https://icons.duckduckgo.com/ip3/${parsed.hostname.toLowerCase()}.ico`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns true when the URL is an http(s) URL pointing at a loopback host
|
|
|
|
|
* (localhost, 127.0.0.1, 0.0.0.0, ::1). Used to decide whether to offer an in-app
|
|
|
|
|
* preview pane instead of opening the system browser.
|
|
|
|
|
*/
|
|
|
|
|
export const isLoopbackHttpUrl = (url: string): boolean => {
|
|
|
|
|
const parsed = parseUrlSafely(url.trim());
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const LOOPBACK_URL_PATTERN
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
= /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d{2,5})?(?:\/[^\s<>"'`\u0000-\u001f]*)?/gi;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extracts loopback http(s) URLs from a free-text string. Returns unique URLs
|
|
|
|
|
* in order of first appearance. Trailing punctuation that is unlikely to be
|
|
|
|
|
* part of a real URL is stripped.
|
|
|
|
|
*/
|
|
|
|
|
export const extractLoopbackUrls = (text: string): string[] => {
|
|
|
|
|
if (!text) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const matches = text.match(LOOPBACK_URL_PATTERN);
|
|
|
|
|
if (!matches || matches.length === 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const out: string[] = [];
|
|
|
|
|
for (const raw of matches) {
|
|
|
|
|
const cleaned = raw.replace(/[),.;:!?'"`]+$/g, '');
|
|
|
|
|
if (!cleaned || !isLoopbackHttpUrl(cleaned)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (seen.has(cleaned)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
seen.add(cleaned);
|
|
|
|
|
out.push(cleaned);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 19:11:45 +08:00
|
|
|
/**
|
|
|
|
|
* Opens an external URL in the system browser.
|
2026-06-03 02:42:00 +03:00
|
|
|
* In desktop runtime, uses the native shell for proper handling.
|
2026-03-20 19:11:45 +08:00
|
|
|
* Falls back to window.open() for web runtime.
|
|
|
|
|
*
|
|
|
|
|
* @param url - The URL to open
|
|
|
|
|
* @returns Promise<boolean> - true if the URL was opened successfully
|
|
|
|
|
*/
|
|
|
|
|
export const openExternalUrl = async (url: string): Promise<boolean> => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const target = url.trim();
|
|
|
|
|
if (!target) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = parseUrlSafely(target);
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalizedTarget = parsed.toString();
|
|
|
|
|
|
2026-03-20 19:22:43 +02:00
|
|
|
const runtimeApis = getRegisteredRuntimeAPIs();
|
|
|
|
|
if (runtimeApis?.runtime?.isVSCode && runtimeApis.vscode?.openExternalUrl) {
|
|
|
|
|
try {
|
|
|
|
|
await runtimeApis.vscode.openExternalUrl(normalizedTarget);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
|
|
|
|
if (desktop?.openExternal) {
|
2026-03-20 19:11:45 +08:00
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
await desktop.openExternal(normalizedTarget);
|
2026-03-20 19:11:45 +08:00
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
// Fall through to window.open
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
window.open(normalizedTarget, '_blank', 'noopener,noreferrer');
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|