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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user