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
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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",
+6
View File
@@ -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 dapplication approuvés',
'settings.openchamber.appLinks.info': 'Les liens de cette liste souvrent sans nouvelle demande sur cet appareil. Les autres liens dapplication demandent toujours une confirmation.',
'settings.openchamber.appLinks.empty': 'Aucun lien dapplication approuvé sur cet appareil. Choisissez « Approuver et ouvrir » lors de louverture dun lien pour lajouter 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',
+6
View File
@@ -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 limplé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': '複製',
+6
View File
@@ -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': '복제',
+6
View File
@@ -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ę',
+6
View File
@@ -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": "Дублювати",
+6
View File
@@ -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': '等待實作者',
+7 -1
View File
@@ -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',
+62
View File
@@ -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);
});
});
+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);