fix(ui): open app deep links from chat after confirmation (#2932)

* fix(ui): open app deep links from chat after confirmation

DOMPurify's default URI policy stripped href from anchors with custom
application schemes (obsidian://, vscode://, ...), so every app link
rendered in chat was dead across web, desktop, VS Code, and mobile.

- Classify safe app-link schemes in lib/url.ts (browser-handled,
  scriptable, webview-internal, network, and self-deep-link schemes
  stay excluded) and let openExternalUrl accept them
- Keep app-link hrefs through the markdown sanitize hook
- Intercept app-link clicks in the markdown renderer and route them
  through a confirmation dialog (Trust and open / Open once, dismiss
  to cancel) mounted in the desktop/web app root and the mobile shell
- Persist per-device trusted schemes in a zustand store; trusted
  schemes open without asking again

* feat(settings): manage trusted app link schemes in General

Add an App links section to Settings > General listing the application
schemes trusted on this device with a delete action; removing a scheme
restores the confirmation dialog for it. Register the section in
settings search.

* fix(ui): enforce app link confirmation

* fix(ui): handle app links by runtime

* fix(vscode): keep app links unsupported

* fix(settings): clarify trusted app links

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
ChangeHow
2026-08-23 01:53:21 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a5b0272f01
commit 3a78d86248
45 changed files with 909 additions and 53 deletions
+63 -5
View File
@@ -20,6 +20,61 @@ export const isExternalHttpUrl = (url: string): boolean => {
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
};
/** Lowercased URL scheme without the trailing colon, or null when unparseable. */
export const getUrlScheme = (url: string): string | null => {
const parsed = parseUrlSafely(url.trim());
if (!parsed) {
return null;
}
return parsed.protocol.replace(/:$/, '').toLowerCase();
};
/**
* Schemes the browser or OS communication apps already handle natively
* (mailto:, tel:, sms:, ...). They are not application deep links.
*/
const BROWSER_HANDLED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'sms', 'callto', 'cid', 'xmpp', 'irc', 'news', 'nntp', 'feed', 'webcal']);
/**
* Schemes that must never be preserved or opened from rendered chat content.
*/
const BLOCKED_APP_LINK_SCHEMES = new Set([
// Scriptable or web-content schemes
'javascript', 'data', 'vbscript', 'blob', 'filesystem', 'about',
// WebView/Electron internal schemes
'chrome', 'chrome-extension', 'devtools', 'moz-extension', 'ms-browser-extension',
// Local files flow through the dedicated file-link handling
'file',
// Network protocols that are not application links
'ws', 'wss', 'ftp', 'ftps',
// Android intent URIs can launch arbitrary components with extras
'intent',
// Historically abused Windows handlers can invoke diagnostic, shell, or
// file-search flows that must not be offered from untrusted chat content.
'ms-msdt', 'search-ms', 'shell',
// OpenChamber's own schemes must not be re-launched from chat content
'openchamber', 'openchamber-ui', 'capacitor',
]);
const APP_LINK_SCHEME_RE = /^[a-z][a-z0-9+.-]{1,31}$/;
/**
* True for custom application deep links such as `obsidian://`, `linear://`,
* or `vscode://`. Browser-handled and dangerous/internal schemes are excluded,
* so a true result means the link may be offered to the user behind a
* confirmation the first time its scheme appears.
*/
export const isAppLinkUrl = (url: string): boolean => {
const scheme = getUrlScheme(url);
if (!scheme) {
return false;
}
if (BROWSER_HANDLED_SCHEMES.has(scheme) || BLOCKED_APP_LINK_SCHEMES.has(scheme)) {
return false;
}
return APP_LINK_SCHEME_RE.test(scheme);
};
export const getExternalFaviconUrl = (url: string): string | null => {
const parsed = parseUrlSafely(url.trim());
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
@@ -88,7 +143,7 @@ export const extractLoopbackUrls = (text: string): string[] => {
* @param url - The URL to open
* @returns Promise<boolean> - true if the URL was opened successfully
*/
export const openExternalUrl = async (url: string): Promise<boolean> => {
const openValidatedExternalUrl = async (url: string): Promise<boolean> => {
if (typeof window === 'undefined') {
return false;
}
@@ -103,10 +158,6 @@ export const openExternalUrl = async (url: string): Promise<boolean> => {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const normalizedTarget = parsed.toString();
const runtimeApis = getRegisteredRuntimeAPIs();
@@ -136,3 +187,10 @@ export const openExternalUrl = async (url: string): Promise<boolean> => {
return false;
}
};
export const openExternalUrl = (url: string): Promise<boolean> =>
isExternalHttpUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false);
/** Opens a classified app link after the caller has completed confirmation. */
export const openConfirmedAppLinkUrl = (url: string): Promise<boolean> =>
isAppLinkUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false);