* 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>
94 lines
3.3 KiB
TypeScript
94 lines
3.3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|