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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a5b0272f01
commit
3a78d86248
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -908,6 +909,7 @@ function App({ apis }: AppProps) {
|
||||
isVSCodeRuntime={isVSCodeRuntime}
|
||||
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
|
||||
/>
|
||||
<AppLinkConfirmDialog />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
@@ -951,6 +953,7 @@ function App({ apis }: AppProps) {
|
||||
<OpenCodeUpdateToast />
|
||||
<MainLayout />
|
||||
<Toaster />
|
||||
<AppLinkConfirmDialog />
|
||||
{!isBootShell && (
|
||||
<>
|
||||
<ConfigUpdateOverlay />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
@@ -325,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<ElectronMiniChatContent config={config} />
|
||||
<AppLinkConfirmDialog />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { PlanView } from '@/components/views/PlanView';
|
||||
import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
@@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||
setConnectionEpoch((value) => value + 1);
|
||||
}} />
|
||||
<AppLinkConfirmDialog />
|
||||
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
|
||||
{isInitialized ? <ConfigUpdateOverlay /> : null}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Toaster } from '@/components/ui/sonner';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||
@@ -110,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<AgentManagerView />
|
||||
<AppLinkConfirmDialog />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
@@ -129,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<VSCodeLayout />
|
||||
<AppLinkConfirmDialog />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
<ConfigUpdateOverlay />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
mock.module('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
|
||||
const {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} = await import('./appLinkConfirmation');
|
||||
|
||||
describe('AppLinkConfirmDialog', () => {
|
||||
beforeEach(() => {
|
||||
if (getAppLinkConfirmationSnapshot()) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps cancel visible and focused beside both open choices', () => {
|
||||
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<AppLinkConfirmDialog />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('>Cancel</button>');
|
||||
expect(markup).toContain('autofocus=""');
|
||||
expect(markup).toContain('>Open once</button>');
|
||||
expect(markup).toContain('>Trust and open</button>');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getUrlScheme } from '@/lib/url';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
settleAppLinkConfirmation,
|
||||
subscribeAppLinkConfirmation,
|
||||
type AppLinkConfirmationChoice,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
/**
|
||||
* App-level dialog confirming application deep links (obsidian://, vscode://,
|
||||
* ...) rendered in chat markdown before the OS is asked to open them.
|
||||
* Dismissing via the close button, Escape, or the backdrop cancels the open.
|
||||
*/
|
||||
export const AppLinkConfirmDialog = () => {
|
||||
const { t } = useI18n();
|
||||
const request = React.useSyncExternalStore(
|
||||
subscribeAppLinkConfirmation,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
);
|
||||
|
||||
const url = request?.url ?? '';
|
||||
const scheme = getUrlScheme(url) ?? '';
|
||||
|
||||
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
|
||||
settleAppLinkConfirmation(choice);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(request)}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
settle('cancel');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{scheme
|
||||
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
|
||||
: t('chat.appLink.confirm.descriptionPlain')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
|
||||
{url}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
|
||||
{t('chat.appLink.confirm.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => settle('trust')}>
|
||||
{t('chat.appLink.confirm.trustAndOpen')}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => settle('open')}>
|
||||
{t('chat.appLink.confirm.open')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,10 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -55,7 +57,7 @@ const useCurrentMermaidTheme = () => {
|
||||
: fallbackLight);
|
||||
};
|
||||
|
||||
const useExternalLinkInteractions = ({
|
||||
const useLinkInteractions = ({
|
||||
containerRef,
|
||||
enabled,
|
||||
}: {
|
||||
@@ -63,48 +65,16 @@ const useExternalLinkInteractions = ({
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(href);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
return attachAppLinkInteractions(container, {
|
||||
allowExternalHttp: enabled !== false,
|
||||
openAppLink: (href) => void openAppLinkWithConfirmation(href),
|
||||
openExternalHttp: (href) => void openExternalUrl(href),
|
||||
});
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
@@ -969,7 +939,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences && !isStreaming,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
useLinkInteractions({ containerRef });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
@@ -1020,6 +990,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
content: string;
|
||||
className?: string;
|
||||
variant?: MarkdownVariant;
|
||||
// App links remain confirmed even where ordinary HTTP link handling is off.
|
||||
disableLinkSafety?: boolean;
|
||||
stripFrontmatter?: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -1061,7 +1032,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
describe('app link confirmation', () => {
|
||||
beforeEach(() => {
|
||||
useAppLinkTrustStore.setState({ trustedSchemes: [] });
|
||||
const pending = getAppLinkConfirmationSnapshot();
|
||||
if (pending) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('opens trusted schemes without asking', async () => {
|
||||
useAppLinkTrustStore.getState().trustScheme('obsidian');
|
||||
|
||||
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
|
||||
});
|
||||
|
||||
test('asks once and trusts the scheme when the user chooses trust', async () => {
|
||||
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
|
||||
|
||||
settleAppLinkConfirmation('trust');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
|
||||
});
|
||||
|
||||
test('cancel opens nothing and keeps the scheme untrusted', async () => {
|
||||
const pending = openAppLinkWithConfirmation('notion://note/xyz');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
|
||||
});
|
||||
|
||||
test('a newer request cancels the pending one', async () => {
|
||||
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
|
||||
const firstChoice = first.then(
|
||||
() => 'settled',
|
||||
() => 'settled',
|
||||
);
|
||||
const second = openAppLinkWithConfirmation('linear://open/1');
|
||||
|
||||
expect(await firstChoice).toBe('settled');
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
|
||||
|
||||
settleAppLinkConfirmation('open');
|
||||
await second;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
|
||||
|
||||
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
|
||||
|
||||
type PendingAppLinkRequest = {
|
||||
url: string;
|
||||
resolve: (choice: AppLinkConfirmationChoice) => void;
|
||||
};
|
||||
|
||||
let pendingRequest: PendingAppLinkRequest | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emitChange = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask the user (via the app-level confirmation dialog) whether an application
|
||||
* deep link may be opened. Resolves immediately when the scheme was trusted
|
||||
* earlier. Only one request is active at a time; a new request cancels the
|
||||
* pending one.
|
||||
*/
|
||||
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
|
||||
const scheme = getUrlScheme(url);
|
||||
if (!scheme) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const trustStore = useAppLinkTrustStore.getState();
|
||||
if (trustStore.isSchemeTrusted(scheme)) {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
|
||||
if (pendingRequest) {
|
||||
pendingRequest.resolve('cancel');
|
||||
}
|
||||
|
||||
return new Promise<AppLinkConfirmationChoice>((resolve) => {
|
||||
pendingRequest = { url, resolve };
|
||||
emitChange();
|
||||
}).then((choice) => {
|
||||
if (choice === 'trust') {
|
||||
useAppLinkTrustStore.getState().trustScheme(scheme);
|
||||
}
|
||||
if (choice === 'open' || choice === 'trust') {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
|
||||
const request = pendingRequest;
|
||||
pendingRequest = null;
|
||||
emitChange();
|
||||
request?.resolve(choice);
|
||||
};
|
||||
|
||||
export const subscribeAppLinkConfirmation = subscribe;
|
||||
export const getAppLinkConfirmationSnapshot = getSnapshot;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
|
||||
const TestElement = class Element {};
|
||||
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
|
||||
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
|
||||
|
||||
class TestAnchor extends HTMLAnchorElement {
|
||||
constructor(private readonly rawHref: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return name === 'href' ? this.rawHref : null;
|
||||
}
|
||||
|
||||
closest(): TestAnchor {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class TestContainer {
|
||||
listeners = new Map<string, EventListener>();
|
||||
|
||||
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
// SAFETY: dispatch constructs every mouse field read by the production listener.
|
||||
this.listeners.set(name, (event) => listener(event as MouseEvent));
|
||||
}
|
||||
|
||||
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
void listener;
|
||||
this.listeners.delete(name);
|
||||
}
|
||||
|
||||
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
|
||||
const event = new Event(name, { cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
target: { value: new TestAnchor(href) },
|
||||
button: { value: init.button ?? 0 },
|
||||
metaKey: { value: init.metaKey ?? false },
|
||||
ctrlKey: { value: init.ctrlKey ?? false },
|
||||
altKey: { value: init.altKey ?? false },
|
||||
shiftKey: { value: init.shiftKey ?? false },
|
||||
});
|
||||
this.listeners.get(name)?.(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
const setup = (allowExternalHttp = true) => {
|
||||
const container = new TestContainer();
|
||||
const appLinks: string[] = [];
|
||||
const httpLinks: string[] = [];
|
||||
const cleanup = attachAppLinkInteractions(container, {
|
||||
allowExternalHttp,
|
||||
openAppLink: (url) => appLinks.push(url),
|
||||
openExternalHttp: (url) => httpLinks.push(url),
|
||||
});
|
||||
return { container, appLinks, httpLinks, cleanup };
|
||||
};
|
||||
|
||||
describe('app link interactions', () => {
|
||||
test('confirms plain, modifier, and middle-click activations', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([href, href, href]);
|
||||
});
|
||||
|
||||
test('blocks drag activation without opening immediately', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
|
||||
const enabled = setup();
|
||||
const disabled = setup(false);
|
||||
const href = 'https://example.com';
|
||||
|
||||
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
|
||||
expect(enabled.httpLinks).toEqual([href]);
|
||||
expect(disabled.httpLinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
|
||||
|
||||
type AppLinkInteractionOptions = {
|
||||
allowExternalHttp: boolean;
|
||||
openAppLink: (url: string) => void;
|
||||
openExternalHttp: (url: string) => void;
|
||||
};
|
||||
|
||||
type LinkInteractionContainer = {
|
||||
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
};
|
||||
|
||||
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return null;
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return null;
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
|
||||
return anchor;
|
||||
};
|
||||
|
||||
const interceptAppLink = (
|
||||
event: MouseEvent | DragEvent,
|
||||
openAppLink?: (url: string) => void,
|
||||
): boolean => {
|
||||
if (event.defaultPrevented) return false;
|
||||
const anchor = findLink(event);
|
||||
const href = anchor?.getAttribute('href') ?? '';
|
||||
if (!isAppLinkUrl(href)) return false;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openAppLink?.(href);
|
||||
return true;
|
||||
};
|
||||
|
||||
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
|
||||
event.button === 0
|
||||
&& !event.metaKey
|
||||
&& !event.ctrlKey
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey
|
||||
);
|
||||
|
||||
export const attachAppLinkInteractions = (
|
||||
container: LinkInteractionContainer,
|
||||
options: AppLinkInteractionOptions,
|
||||
): (() => void) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (interceptAppLink(event, options.openAppLink)) return;
|
||||
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
|
||||
|
||||
const href = findLink(event)?.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
options.openExternalHttp(href);
|
||||
};
|
||||
const handleAuxClick = (event: MouseEvent) => {
|
||||
if (event.button === 1) interceptAppLink(event, options.openAppLink);
|
||||
};
|
||||
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
|
||||
interceptAppLink(event);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('auxclick', handleAuxClick);
|
||||
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('auxclick', handleAuxClick);
|
||||
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,43 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type SanitizeAttribute = {
|
||||
attrName: string;
|
||||
attrValue: string;
|
||||
forceKeepAttr?: boolean;
|
||||
};
|
||||
|
||||
class TestAnchorElement {
|
||||
target = '';
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
if (name === 'target') this.target = value;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeHooks: {
|
||||
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
|
||||
afterSanitizeAttributes?: (node: unknown) => void;
|
||||
} = {};
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {},
|
||||
HTMLAnchorElement: TestAnchorElement,
|
||||
});
|
||||
|
||||
mock.module('dompurify', () => ({
|
||||
default: {
|
||||
isSupported: true,
|
||||
addHook: () => undefined,
|
||||
sanitize: (html: string) => html,
|
||||
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
|
||||
sanitizeHooks[name] = hook;
|
||||
},
|
||||
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
|
||||
const anchor = new TestAnchorElement();
|
||||
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
|
||||
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
|
||||
sanitizeHooks.afterSanitizeAttributes?.(anchor);
|
||||
|
||||
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown-worker', () => ({
|
||||
@@ -40,6 +73,21 @@ describe('markdown sanitization', () => {
|
||||
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
|
||||
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps app and local file links while stripping blocked schemes', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[app](obsidian://open?vault=Notebook)',
|
||||
'[file](file:///workspace/notes.md)',
|
||||
'[script](javascript:alert(1))',
|
||||
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
|
||||
].join('\n\n'), 'inline');
|
||||
|
||||
expect(html).toContain('href="obsidian://open?vault=Notebook"');
|
||||
expect(html).toContain('href="file:///workspace/notes.md"');
|
||||
expect(html).not.toContain('href="javascript:alert(1)"');
|
||||
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isAppLinkUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
@@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => {
|
||||
sanitizeHookInstalled = true;
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
|
||||
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
// DOMPurify's default URI policy strips custom application schemes
|
||||
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
|
||||
// stay excluded via isAppLinkUrl and clicks go through confirmation.
|
||||
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
});
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return;
|
||||
@@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parseBlock = async (
|
||||
block: MarkdownBlock,
|
||||
imageMode: MarkdownImageMode,
|
||||
): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
@@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
|
||||
export const renderMarkdownSync = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): string => {
|
||||
if (!text) return '';
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = parser.parse(text) as string;
|
||||
|
||||
@@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- Assistant markdown treats raw HTML as inert visible text. The final generated
|
||||
HTML is sanitized as defense in depth, with script and style elements
|
||||
forbidden, so message content cannot inject active DOM or application-wide
|
||||
CSS into any runtime surface.
|
||||
CSS into any runtime surface. Safe custom application links go through the
|
||||
app-link confirmation flow in every supported renderer, including VS Code.
|
||||
- Final assistant Markdown rendering is independent from image gallery
|
||||
extraction: gallery presence never changes the chat body. Assistant image
|
||||
syntax consistently renders as a shared image icon followed by its filename,
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -416,6 +416,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.cancel': 'Abbrechen',
|
||||
'settings.common.actions.create': 'Erstellen',
|
||||
'settings.common.actions.delete': 'Löschen',
|
||||
'settings.openchamber.appLinks.title': 'Vertrauenswürdige App-Links',
|
||||
'settings.openchamber.appLinks.info': 'Hier aufgeführte Links öffnen sich auf diesem Gerät ohne erneute Nachfrage. Bei anderen App-Links wird vor dem Öffnen immer nachgefragt.',
|
||||
'settings.openchamber.appLinks.empty': 'Keine vertrauenswürdigen App-Links auf diesem Gerät. Wähle beim Öffnen eines Links „Vertrauen und öffnen“, um ihn hier hinzuzufügen.',
|
||||
'settings.openchamber.appLinks.removeAria': 'Vertraute {scheme}-Links entfernen',
|
||||
'settings.common.actions.reset': 'Zurücksetzen',
|
||||
'settings.common.actions.rename': 'Umbenennen',
|
||||
'settings.common.actions.duplicate': 'Duplizieren',
|
||||
|
||||
@@ -1392,6 +1392,12 @@ export const dict = {
|
||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Sitzungsverzeichnis ist nicht verfügbar',
|
||||
'diffView.reviewDialog.toast.startFailed': 'Fehler beim Starten des Überprüfungsflusses',
|
||||
'chat.history.loadOlder': 'Ältere Nachrichten laden',
|
||||
'chat.appLink.confirm.title': 'Diesen Link in einer anderen App öffnen?',
|
||||
'chat.appLink.confirm.description': 'Dieser Chat-Link verwendet das {scheme}-Protokoll und wird in einer anderen App geöffnet.',
|
||||
'chat.appLink.confirm.descriptionPlain': 'Dieser Chat-Link wird in einer anderen App geöffnet.',
|
||||
'chat.appLink.confirm.cancel': 'Abbrechen',
|
||||
'chat.appLink.confirm.open': 'Einmal öffnen',
|
||||
'chat.appLink.confirm.trustAndOpen': 'Vertrauen und öffnen',
|
||||
'chat.autoReview.title': 'Code-Überprüfungs-Schleife läuft',
|
||||
'chat.autoReview.status.waitingForReviewer': 'Warte auf Überprüfer',
|
||||
'chat.autoReview.status.waitingForImplementer': 'Warte auf Implementierer',
|
||||
|
||||
@@ -433,6 +433,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.cancel': 'Cancel',
|
||||
'settings.common.actions.create': 'Create',
|
||||
'settings.common.actions.delete': 'Delete',
|
||||
'settings.openchamber.appLinks.title': 'Trusted app links',
|
||||
'settings.openchamber.appLinks.info': 'Links listed here open without asking again on this device. Other app links always ask before opening.',
|
||||
'settings.openchamber.appLinks.empty': 'No trusted app links on this device. Choose "Trust and open" when opening a link to add it here.',
|
||||
'settings.openchamber.appLinks.removeAria': 'Remove trusted {scheme} links',
|
||||
'settings.common.actions.reset': 'Reset',
|
||||
'settings.common.actions.rename': 'Rename',
|
||||
'settings.common.actions.duplicate': 'Duplicate',
|
||||
|
||||
@@ -1549,6 +1549,12 @@ export const dict = {
|
||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
||||
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
|
||||
'chat.history.loadOlder': 'Load older messages',
|
||||
'chat.appLink.confirm.title': 'Open this link in another application?',
|
||||
'chat.appLink.confirm.description': 'This chat link uses the {scheme} protocol and will open in another application.',
|
||||
'chat.appLink.confirm.descriptionPlain': 'This chat link will open in another application.',
|
||||
'chat.appLink.confirm.cancel': 'Cancel',
|
||||
'chat.appLink.confirm.open': 'Open once',
|
||||
'chat.appLink.confirm.trustAndOpen': 'Trust and open',
|
||||
'chat.autoReview.title': 'Code review loop is running',
|
||||
'chat.autoReview.status.waitingForReviewer': 'Waiting for reviewer',
|
||||
'chat.autoReview.status.waitingForImplementer': 'Waiting for implementer',
|
||||
|
||||
@@ -401,6 +401,10 @@ export const settingsDict = {
|
||||
"settings.common.actions.cancel": "Cancelar",
|
||||
"settings.common.actions.create": "Crear",
|
||||
"settings.common.actions.delete": "Eliminar",
|
||||
"settings.openchamber.appLinks.title": "Enlaces de aplicaciones de confianza",
|
||||
"settings.openchamber.appLinks.info": "Los enlaces de esta lista se abren sin volver a preguntar en este dispositivo. Los demás enlaces de aplicaciones siempre piden confirmación.",
|
||||
"settings.openchamber.appLinks.empty": "No hay enlaces de aplicaciones de confianza en este dispositivo. Elige \"Confiar y abrir\" al abrir un enlace para añadirlo aquí.",
|
||||
"settings.openchamber.appLinks.removeAria": "Quitar los enlaces {scheme} de confianza",
|
||||
"settings.common.actions.reset": "Restablecer",
|
||||
"settings.common.actions.rename": "Cambiar nombre",
|
||||
"settings.common.actions.duplicate": "Duplicar",
|
||||
|
||||
@@ -1527,6 +1527,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.dictation.retry': 'Reintentar transcripción',
|
||||
'chat.dictation.discard': 'Descartar grabación',
|
||||
'chat.history.loadOlder': 'Cargar mensajes anteriores',
|
||||
"chat.appLink.confirm.title": "¿Abrir este enlace en otra aplicación?",
|
||||
"chat.appLink.confirm.description": "Este enlace del chat usa el protocolo {scheme} y se abrirá en otra aplicación.",
|
||||
"chat.appLink.confirm.descriptionPlain": "Este enlace del chat se abrirá en otra aplicación.",
|
||||
"chat.appLink.confirm.cancel": "Cancelar",
|
||||
"chat.appLink.confirm.open": "Abrir una vez",
|
||||
"chat.appLink.confirm.trustAndOpen": "Confiar y abrir",
|
||||
'chat.autoReview.title': 'El ciclo de revisión de código está en curso',
|
||||
'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor',
|
||||
'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador',
|
||||
|
||||
@@ -324,6 +324,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.cancel': 'Annuler',
|
||||
'settings.common.actions.create': 'Créer',
|
||||
'settings.common.actions.delete': 'Supprimer',
|
||||
'settings.openchamber.appLinks.title': 'Liens d’application approuvés',
|
||||
'settings.openchamber.appLinks.info': 'Les liens de cette liste s’ouvrent sans nouvelle demande sur cet appareil. Les autres liens d’application demandent toujours une confirmation.',
|
||||
'settings.openchamber.appLinks.empty': 'Aucun lien d’application approuvé sur cet appareil. Choisissez « Approuver et ouvrir » lors de l’ouverture d’un lien pour l’ajouter ici.',
|
||||
'settings.openchamber.appLinks.removeAria': 'Supprimer les liens {scheme} approuvés',
|
||||
'settings.common.actions.reset': 'Réinitialiser',
|
||||
'settings.common.actions.rename': 'Rebaptiser',
|
||||
'settings.common.actions.duplicate': 'Dupliquer',
|
||||
|
||||
@@ -1314,6 +1314,12 @@ export const dict = {
|
||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible',
|
||||
'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue',
|
||||
'chat.history.loadOlder': 'Charger les messages précédents',
|
||||
'chat.appLink.confirm.title': 'Ouvrir ce lien dans une autre application ?',
|
||||
'chat.appLink.confirm.description': "Ce lien de discussion utilise le protocole {scheme} et s'ouvrira dans une autre application.",
|
||||
'chat.appLink.confirm.descriptionPlain': "Ce lien de discussion s'ouvrira dans une autre application.",
|
||||
'chat.appLink.confirm.cancel': 'Annuler',
|
||||
'chat.appLink.confirm.open': 'Ouvrir une fois',
|
||||
'chat.appLink.confirm.trustAndOpen': 'Approuver et ouvrir',
|
||||
'chat.autoReview.title': 'La boucle de revue de code est en cours',
|
||||
'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer',
|
||||
'chat.autoReview.status.waitingForImplementer': 'En attente de l’implémenteur',
|
||||
|
||||
@@ -434,6 +434,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.cancel': 'キャンセル',
|
||||
'settings.common.actions.create': '作成',
|
||||
'settings.common.actions.delete': '削除',
|
||||
'settings.openchamber.appLinks.title': '信頼済みのアプリリンク',
|
||||
'settings.openchamber.appLinks.info': 'ここに表示されたリンクは、このデバイスでは次回から確認せずに開きます。その他のアプリリンクは開く前に必ず確認します。',
|
||||
'settings.openchamber.appLinks.empty': 'このデバイスには信頼済みのアプリリンクがありません。リンクを開く際に「信頼して開く」を選ぶとここに追加されます。',
|
||||
'settings.openchamber.appLinks.removeAria': '信頼済みの {scheme} リンクを削除',
|
||||
'settings.common.actions.reset': 'リセット',
|
||||
'settings.common.actions.rename': '名前変更',
|
||||
'settings.common.actions.duplicate': '複製',
|
||||
|
||||
@@ -1554,6 +1554,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。',
|
||||
'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。',
|
||||
'chat.history.loadOlder': '以前のメッセージを読み込む',
|
||||
'chat.appLink.confirm.title': 'このリンクを別のアプリで開きますか?',
|
||||
'chat.appLink.confirm.description': 'このチャットのリンクは {scheme} プロトコルを使用し、別のアプリで開かれます。',
|
||||
'chat.appLink.confirm.descriptionPlain': 'このチャットのリンクは別のアプリで開かれます。',
|
||||
'chat.appLink.confirm.cancel': 'キャンセル',
|
||||
'chat.appLink.confirm.open': '一度だけ開く',
|
||||
'chat.appLink.confirm.trustAndOpen': '信頼して開く',
|
||||
'chat.autoReview.title': 'コードレビューループが実行中です',
|
||||
'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中',
|
||||
'chat.autoReview.status.waitingForImplementer': '実装者を待機中',
|
||||
|
||||
@@ -401,6 +401,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.cancel': '취소',
|
||||
'settings.common.actions.create': '생성',
|
||||
'settings.common.actions.delete': '삭제',
|
||||
'settings.openchamber.appLinks.title': '신뢰한 앱 링크',
|
||||
'settings.openchamber.appLinks.info': '여기에 표시된 링크는 이 기기에서 다시 묻지 않고 열립니다. 그 밖의 앱 링크는 열기 전에 항상 확인합니다.',
|
||||
'settings.openchamber.appLinks.empty': '이 기기에 신뢰한 앱 링크가 없습니다. 링크를 열 때 "신뢰하고 열기"를 선택하면 여기에 추가됩니다.',
|
||||
'settings.openchamber.appLinks.removeAria': '신뢰된 {scheme} 링크 제거',
|
||||
'settings.common.actions.reset': '초기화',
|
||||
'settings.common.actions.rename': '이름 변경',
|
||||
'settings.common.actions.duplicate': '복제',
|
||||
|
||||
@@ -1551,6 +1551,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다',
|
||||
'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다',
|
||||
'chat.history.loadOlder': '이전 메시지 불러오기',
|
||||
'chat.appLink.confirm.title': '이 링크를 다른 앱에서 열까요?',
|
||||
'chat.appLink.confirm.description': '이 채팅 링크는 {scheme} 프로토콜을 사용하며 다른 앱에서 열립니다.',
|
||||
'chat.appLink.confirm.descriptionPlain': '이 채팅 링크는 다른 앱에서 열립니다.',
|
||||
'chat.appLink.confirm.cancel': '취소',
|
||||
'chat.appLink.confirm.open': '한 번만 열기',
|
||||
'chat.appLink.confirm.trustAndOpen': '신뢰하고 열기',
|
||||
'chat.autoReview.title': '코드 리뷰 루프 실행 중',
|
||||
'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중',
|
||||
'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중',
|
||||
|
||||
@@ -216,6 +216,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.copyAll': 'Kopiuj wszystko',
|
||||
'settings.common.actions.create': 'Utwórz',
|
||||
'settings.common.actions.delete': 'Usuń',
|
||||
'settings.openchamber.appLinks.title': 'Zaufane linki aplikacji',
|
||||
'settings.openchamber.appLinks.info': 'Linki z tej listy otwierają się na tym urządzeniu bez ponownego pytania. Inne linki aplikacji zawsze wymagają potwierdzenia.',
|
||||
'settings.openchamber.appLinks.empty': 'Brak zaufanych linków aplikacji na tym urządzeniu. Wybierz „Zaufaj i otwórz” podczas otwierania linku, aby dodać go tutaj.',
|
||||
'settings.openchamber.appLinks.removeAria': 'Usuń zaufane linki {scheme}',
|
||||
'settings.common.actions.duplicate': 'Duplikuj',
|
||||
'settings.common.actions.import': 'Importuj',
|
||||
'settings.common.actions.rename': 'Zmień nazwę',
|
||||
|
||||
@@ -1763,6 +1763,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny',
|
||||
'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review',
|
||||
'chat.history.loadOlder': 'Wczytaj starsze wiadomości',
|
||||
'chat.appLink.confirm.title': 'Otworzyć ten link w innej aplikacji?',
|
||||
'chat.appLink.confirm.description': 'Ten link z czatu używa protokołu {scheme} i zostanie otwarty w innej aplikacji.',
|
||||
'chat.appLink.confirm.descriptionPlain': 'Ten link z czatu zostanie otwarty w innej aplikacji.',
|
||||
'chat.appLink.confirm.cancel': 'Anuluj',
|
||||
'chat.appLink.confirm.open': 'Otwórz raz',
|
||||
'chat.appLink.confirm.trustAndOpen': 'Zaufaj i otwórz',
|
||||
'chat.autoReview.title': 'Pętla code review trwa',
|
||||
'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera',
|
||||
'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora',
|
||||
|
||||
@@ -401,6 +401,10 @@ export const settingsDict = {
|
||||
"settings.common.actions.cancel": "Cancelar",
|
||||
"settings.common.actions.create": "Criar",
|
||||
"settings.common.actions.delete": "Excluir",
|
||||
"settings.openchamber.appLinks.title": "Links de aplicativos confiáveis",
|
||||
"settings.openchamber.appLinks.info": "Os links desta lista abrem sem perguntar novamente neste dispositivo. Outros links de aplicativos sempre pedem confirmação antes de abrir.",
|
||||
"settings.openchamber.appLinks.empty": "Não há links de aplicativos confiáveis neste dispositivo. Escolha \"Confiar e abrir\" ao abrir um link para adicioná-lo aqui.",
|
||||
"settings.openchamber.appLinks.removeAria": "Remover links {scheme} confiáveis",
|
||||
"settings.common.actions.reset": "Reiniciar",
|
||||
"settings.common.actions.rename": "Renomear",
|
||||
"settings.common.actions.duplicate": "Duplicar",
|
||||
|
||||
@@ -1527,6 +1527,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.dictation.retry': 'Tentar transcrever novamente',
|
||||
'chat.dictation.discard': 'Descartar gravação',
|
||||
'chat.history.loadOlder': 'Carregar mensagens anteriores',
|
||||
"chat.appLink.confirm.title": "Abrir este link em outro aplicativo?",
|
||||
"chat.appLink.confirm.description": "Este link do chat usa o protocolo {scheme} e será aberto em outro aplicativo.",
|
||||
"chat.appLink.confirm.descriptionPlain": "Este link do chat será aberto em outro aplicativo.",
|
||||
"chat.appLink.confirm.cancel": "Cancelar",
|
||||
"chat.appLink.confirm.open": "Abrir uma vez",
|
||||
"chat.appLink.confirm.trustAndOpen": "Confiar e abrir",
|
||||
'chat.autoReview.title': 'O ciclo de revisão de código está em andamento',
|
||||
'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor',
|
||||
'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador',
|
||||
|
||||
@@ -401,6 +401,10 @@ export const settingsDict = {
|
||||
"settings.common.actions.cancel": "Скасувати",
|
||||
"settings.common.actions.create": "Створити",
|
||||
"settings.common.actions.delete": "Видалити",
|
||||
"settings.openchamber.appLinks.title": "Довірені посилання програм",
|
||||
"settings.openchamber.appLinks.info": "Посилання в цьому списку відкриваються без повторного запиту на цьому пристрої. Для інших посилань програм ми завжди просимо підтвердження.",
|
||||
"settings.openchamber.appLinks.empty": "На цьому пристрої ще немає довірених посилань програм. Виберіть «Довірити і відкрити» під час відкриття посилання, щоб додати його сюди.",
|
||||
"settings.openchamber.appLinks.removeAria": "Видалити довірені посилання {scheme}",
|
||||
"settings.common.actions.reset": "Скинути",
|
||||
"settings.common.actions.rename": "Перейменувати",
|
||||
"settings.common.actions.duplicate": "Дублювати",
|
||||
|
||||
@@ -1527,6 +1527,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.dictation.retry': 'Повторити розшифровку',
|
||||
'chat.dictation.discard': 'Відхилити запис',
|
||||
'chat.history.loadOlder': 'Завантажити ще',
|
||||
"chat.appLink.confirm.title": "Відкрити це посилання в іншій програмі?",
|
||||
"chat.appLink.confirm.description": "Це посилання з чату використовує протокол {scheme} і буде відкрито в іншій програмі.",
|
||||
"chat.appLink.confirm.descriptionPlain": "Це посилання з чату буде відкрито в іншій програмі.",
|
||||
"chat.appLink.confirm.cancel": "Скасувати",
|
||||
"chat.appLink.confirm.open": "Відкрити один раз",
|
||||
"chat.appLink.confirm.trustAndOpen": "Довірити і відкрити",
|
||||
'chat.autoReview.title': 'Цикл код-ревʼю триває',
|
||||
'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера',
|
||||
'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора',
|
||||
|
||||
@@ -401,6 +401,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.cancel': '取消',
|
||||
'settings.common.actions.create': '创建',
|
||||
'settings.common.actions.delete': '删除',
|
||||
'settings.openchamber.appLinks.title': '受信任的应用链接',
|
||||
'settings.openchamber.appLinks.info': '此列表中的链接在本设备上打开时不再询问。其他应用链接在打开前始终需要确认。',
|
||||
'settings.openchamber.appLinks.empty': '本设备上暂无受信任的应用链接。打开链接时选择“信任并打开”即可添加到这里。',
|
||||
'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 链接',
|
||||
'settings.common.actions.reset': '重置',
|
||||
'settings.common.actions.rename': '重命名',
|
||||
'settings.common.actions.duplicate': '复制',
|
||||
|
||||
@@ -1515,6 +1515,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
||||
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
|
||||
'chat.history.loadOlder': '加载更早的消息',
|
||||
'chat.appLink.confirm.title': '要在其他应用中打开此链接吗?',
|
||||
'chat.appLink.confirm.description': '此聊天链接使用 {scheme} 协议,将在其他应用中打开。',
|
||||
'chat.appLink.confirm.descriptionPlain': '此聊天链接将在其他应用中打开。',
|
||||
'chat.appLink.confirm.cancel': '取消',
|
||||
'chat.appLink.confirm.open': '打开一次',
|
||||
'chat.appLink.confirm.trustAndOpen': '信任并打开',
|
||||
'chat.autoReview.title': '代码审查循环正在运行',
|
||||
'chat.autoReview.status.waitingForReviewer': '等待审查者',
|
||||
'chat.autoReview.status.waitingForImplementer': '等待实现者',
|
||||
|
||||
@@ -398,6 +398,10 @@ export const settingsDict = {
|
||||
'settings.common.actions.cancel': '取消',
|
||||
'settings.common.actions.create': '建立',
|
||||
'settings.common.actions.delete': '刪除',
|
||||
'settings.openchamber.appLinks.title': '受信任的應用程式連結',
|
||||
'settings.openchamber.appLinks.info': '此清單中的連結在這台裝置上開啟時不再詢問。其他應用程式連結在開啟前一律需要確認。',
|
||||
'settings.openchamber.appLinks.empty': '這台裝置上目前沒有受信任的應用程式連結。開啟連結時選擇「信任並開啟」即可加入這裡。',
|
||||
'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 連結',
|
||||
'settings.common.actions.reset': '重設',
|
||||
'settings.common.actions.rename': '重新命名',
|
||||
'settings.common.actions.duplicate': '複製',
|
||||
|
||||
@@ -1525,6 +1525,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
||||
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
|
||||
'chat.history.loadOlder': '載入更早的訊息',
|
||||
'chat.appLink.confirm.title': '要在其他應用程式中開啟此連結嗎?',
|
||||
'chat.appLink.confirm.description': '此聊天連結使用 {scheme} 通訊協定,將在其他應用程式中開啟。',
|
||||
'chat.appLink.confirm.descriptionPlain': '此聊天連結將在其他應用程式中開啟。',
|
||||
'chat.appLink.confirm.cancel': '取消',
|
||||
'chat.appLink.confirm.open': '開啟一次',
|
||||
'chat.appLink.confirm.trustAndOpen': '信任並開啟',
|
||||
'chat.autoReview.title': '程式碼審查循環執行中',
|
||||
'chat.autoReview.status.waitingForReviewer': '等待審查者',
|
||||
'chat.autoReview.status.waitingForImplementer': '等待實作者',
|
||||
|
||||
@@ -50,7 +50,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
page: 'appearance',
|
||||
titleKey: 'settings.openchamber.visual.field.weekStartsOn',
|
||||
keywords: ['calendar', 'monday', 'sunday'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'appearance.light-theme',
|
||||
@@ -177,6 +176,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint',
|
||||
keywords: ['telemetry', 'analytics'],
|
||||
},
|
||||
{
|
||||
id: 'general.app-links',
|
||||
page: 'general',
|
||||
titleKey: 'settings.openchamber.appLinks.title',
|
||||
descriptionKey: 'settings.openchamber.appLinks.info',
|
||||
keywords: ['security', 'app link', 'deep link', 'scheme', 'protocol', 'obsidian', 'notion'],
|
||||
},
|
||||
{
|
||||
id: 'chat.render-mode',
|
||||
page: 'chat',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getUrlScheme, isAppLinkUrl } from '@/lib/url';
|
||||
|
||||
describe('getUrlScheme', () => {
|
||||
test('extracts the lowercased scheme', () => {
|
||||
expect(getUrlScheme('Obsidian://open?vault=X')).toBe('obsidian');
|
||||
expect(getUrlScheme('https://example.test')).toBe('https');
|
||||
});
|
||||
|
||||
test('returns null for unparseable values', () => {
|
||||
expect(getUrlScheme('')).toBeNull();
|
||||
expect(getUrlScheme('not a url')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAppLinkUrl', () => {
|
||||
test('accepts custom application schemes', () => {
|
||||
expect(isAppLinkUrl('obsidian://open?vault=Notebook&file=a%20b')).toBe(true);
|
||||
expect(isAppLinkUrl('vscode://file/path/to/file.ts')).toBe(true);
|
||||
expect(isAppLinkUrl('linear://issue/ABC-1')).toBe(true);
|
||||
expect(isAppLinkUrl('notion://note/xyz')).toBe(true);
|
||||
expect(isAppLinkUrl('slack://channel?id=C123')).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects browser and communication schemes', () => {
|
||||
expect(isAppLinkUrl('https://example.test')).toBe(false);
|
||||
expect(isAppLinkUrl('http://example.test')).toBe(false);
|
||||
expect(isAppLinkUrl('mailto:user@example.test')).toBe(false);
|
||||
expect(isAppLinkUrl('tel:+1234567890')).toBe(false);
|
||||
expect(isAppLinkUrl('sms:+1234567890')).toBe(false);
|
||||
expect(isAppLinkUrl('webcal://example.test/cal.ics')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects dangerous and internal schemes', () => {
|
||||
expect(isAppLinkUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isAppLinkUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe(false);
|
||||
expect(isAppLinkUrl('vbscript:msgbox(1)')).toBe(false);
|
||||
expect(isAppLinkUrl('blob:https://example.test/uuid')).toBe(false);
|
||||
expect(isAppLinkUrl('about:blank')).toBe(false);
|
||||
expect(isAppLinkUrl('file:///etc/passwd')).toBe(false);
|
||||
expect(isAppLinkUrl('ws://localhost:8080')).toBe(false);
|
||||
expect(isAppLinkUrl('ftp://files.example.test')).toBe(false);
|
||||
expect(isAppLinkUrl('intent://scan/#Intent;scheme=zxing;end')).toBe(false);
|
||||
expect(isAppLinkUrl('chrome://settings')).toBe(false);
|
||||
expect(isAppLinkUrl('devtools://devtools/bundled/inspector.html')).toBe(false);
|
||||
expect(isAppLinkUrl('ms-msdt:/id%20PCWDiagnostic')).toBe(false);
|
||||
expect(isAppLinkUrl('search-ms:query=report')).toBe(false);
|
||||
expect(isAppLinkUrl('shell:AppsFolder')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects OpenChamber and Capacitor self-deep-links', () => {
|
||||
expect(isAppLinkUrl('openchamber://connect?host=x')).toBe(false);
|
||||
expect(isAppLinkUrl('openchamber-ui://app/index.html')).toBe(false);
|
||||
expect(isAppLinkUrl('capacitor://localhost/index.html')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects malformed input', () => {
|
||||
expect(isAppLinkUrl('')).toBe(false);
|
||||
expect(isAppLinkUrl('random text')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { useAppLinkTrustStore, MAX_TRUSTED_SCHEMES } from './appLinkTrustStore';
|
||||
|
||||
describe('app link trust store', () => {
|
||||
beforeEach(() => {
|
||||
useAppLinkTrustStore.setState({ trustedSchemes: [] });
|
||||
});
|
||||
|
||||
test('trusts a scheme with case and whitespace normalization', () => {
|
||||
const store = useAppLinkTrustStore.getState();
|
||||
|
||||
store.trustScheme(' Obsidian ');
|
||||
|
||||
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian']);
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('OBSIDIAN')).toBe(true);
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(false);
|
||||
});
|
||||
|
||||
test('re-trusting moves the scheme to the front without duplicates', () => {
|
||||
const store = useAppLinkTrustStore.getState();
|
||||
store.trustScheme('obsidian');
|
||||
store.trustScheme('linear');
|
||||
store.trustScheme('obsidian');
|
||||
|
||||
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian', 'linear']);
|
||||
});
|
||||
|
||||
test('removes a trusted scheme', () => {
|
||||
const store = useAppLinkTrustStore.getState();
|
||||
store.trustScheme('obsidian');
|
||||
store.trustScheme('linear');
|
||||
|
||||
useAppLinkTrustStore.getState().removeTrustedScheme('obsidian');
|
||||
|
||||
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['linear']);
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(false);
|
||||
});
|
||||
|
||||
test('caps the stored scheme list', () => {
|
||||
const store = useAppLinkTrustStore.getState();
|
||||
for (let index = 0; index < MAX_TRUSTED_SCHEMES + 5; index += 1) {
|
||||
store.trustScheme(`scheme${index}`);
|
||||
}
|
||||
|
||||
const schemes = useAppLinkTrustStore.getState().trustedSchemes;
|
||||
expect(schemes).toHaveLength(MAX_TRUSTED_SCHEMES);
|
||||
expect(schemes[0]).toBe(`scheme${MAX_TRUSTED_SCHEMES + 4}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage';
|
||||
|
||||
export const MAX_TRUSTED_SCHEMES = 64;
|
||||
|
||||
interface AppLinkTrustState {
|
||||
/** Application deep-link schemes (obsidian, vscode, ...) the user chose to always allow. */
|
||||
trustedSchemes: string[];
|
||||
trustScheme: (scheme: string) => void;
|
||||
removeTrustedScheme: (scheme: string) => void;
|
||||
isSchemeTrusted: (scheme: string) => boolean;
|
||||
}
|
||||
|
||||
const normalizeScheme = (scheme: string): string => scheme.trim().toLowerCase();
|
||||
|
||||
/**
|
||||
* Per-device trust for application deep links rendered in chat. Security
|
||||
* decisions do not roam, so this persists locally through the shared safe
|
||||
* storage rather than server-synced settings.
|
||||
*/
|
||||
export const useAppLinkTrustStore = create<AppLinkTrustState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
trustedSchemes: [],
|
||||
trustScheme: (scheme) => {
|
||||
const normalized = normalizeScheme(scheme);
|
||||
if (!normalized) return;
|
||||
set((state) => {
|
||||
const next = [normalized, ...state.trustedSchemes.filter((entry) => entry !== normalized)];
|
||||
return { trustedSchemes: next.slice(0, MAX_TRUSTED_SCHEMES) };
|
||||
});
|
||||
},
|
||||
removeTrustedScheme: (scheme) => {
|
||||
const normalized = normalizeScheme(scheme);
|
||||
set((state) => ({ trustedSchemes: state.trustedSchemes.filter((entry) => entry !== normalized) }));
|
||||
},
|
||||
isSchemeTrusted: (scheme) => get().trustedSchemes.includes(normalizeScheme(scheme)),
|
||||
}),
|
||||
{
|
||||
name: 'app-link-trust-store',
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
version: 1,
|
||||
partialize: (state) => ({ trustedSchemes: state.trustedSchemes }),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user