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 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',
+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 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': '複製',
+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': '等待實作者',