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
@@ -0,0 +1,48 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
/**
* Security section for application deep links (obsidian://, notion://, ...)
* that the user chose to always allow from chat. Removing a scheme restores
* the confirmation dialog for it.
*/
export const AppLinkSecuritySettings: React.FC = () => {
const { t } = useI18n();
const trustedSchemes = useAppLinkTrustStore((state) => state.trustedSchemes);
const removeTrustedScheme = useAppLinkTrustStore((state) => state.removeTrustedScheme);
return (
<SettingsSection
title={t('settings.openchamber.appLinks.title')}
description={t('settings.openchamber.appLinks.info')}
>
<div className="space-y-1" data-settings-item="general.app-links">
{trustedSchemes.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{t('settings.openchamber.appLinks.empty')}
</p>
) : (
trustedSchemes.map((scheme) => (
<div key={scheme} className="flex items-center justify-between gap-2 py-0.5">
<span className="min-w-0 truncate font-mono text-[13px]">{`${scheme}://`}</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => removeTrustedScheme(scheme)}
className="!font-normal text-muted-foreground hover:text-foreground"
aria-label={t('settings.openchamber.appLinks.removeAria', { scheme: `${scheme}://` })}
>
{t('settings.common.actions.delete')}
</Button>
</div>
))
)}
</div>
</SettingsSection>
);
};
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { PasskeySettings } from './PasskeySettings';
import { AppLinkSecuritySettings } from './AppLinkSecuritySettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { NotificationSettings } from './NotificationSettings';
@@ -55,6 +56,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<SessionRetentionSettings />
<AppLinkSecuritySettings />
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
{showAbout && <AboutSettings />}
</SettingsPageLayout>
@@ -145,6 +147,7 @@ const GeneralSectionContent: React.FC = () => {
<>
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{showPasskeySettings && <PasskeySettings />}
<AppLinkSecuritySettings />
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<OpenChamberVisualSettings visibleSettings={[