diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index deec5a0f30ef..ca36d8931fec 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -86786,6 +86786,40 @@ } } }, + "fileExplorer.contextMenu.downloadToLocal": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تنزيل إلى الجهاز المحلي…" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Preuzmi lokalno…" } }, + "da": { "stringUnit": { "state": "translated", "value": "Download lokalt…" } }, + "de": { "stringUnit": { "state": "translated", "value": "Lokal herunterladen…" } }, + "es": { "stringUnit": { "state": "translated", "value": "Descargar en local…" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Télécharger en local…" } }, + "it": { "stringUnit": { "state": "translated", "value": "Scarica in locale…" } }, + "ko": { "stringUnit": { "state": "translated", "value": "로컬로 다운로드…" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Last ned lokalt…" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Pobierz lokalnie…" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Baixar localmente…" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Скачать локально…" } }, + "th": { "stringUnit": { "state": "translated", "value": "ดาวน์โหลดไปยังเครื่องนี้…" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Yerel olarak indir…" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Завантажити локально…" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载到本地…" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載到本機…" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download to Local…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルにダウンロード…" + } + } + } + }, "fileExplorer.contextMenu.insertPath": { "extractionState": "manual", "localizations": { @@ -86871,6 +86905,482 @@ } } }, + "fileExplorer.download.alertOK": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "حسنًا" } }, + "bs": { "stringUnit": { "state": "translated", "value": "U redu" } }, + "da": { "stringUnit": { "state": "translated", "value": "OK" } }, + "de": { "stringUnit": { "state": "translated", "value": "OK" } }, + "es": { "stringUnit": { "state": "translated", "value": "Aceptar" } }, + "fr": { "stringUnit": { "state": "translated", "value": "OK" } }, + "it": { "stringUnit": { "state": "translated", "value": "OK" } }, + "ko": { "stringUnit": { "state": "translated", "value": "확인" } }, + "nb": { "stringUnit": { "state": "translated", "value": "OK" } }, + "pl": { "stringUnit": { "state": "translated", "value": "OK" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "OK" } }, + "ru": { "stringUnit": { "state": "translated", "value": "ОК" } }, + "th": { "stringUnit": { "state": "translated", "value": "ตกลง" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Tamam" } }, + "uk": { "stringUnit": { "state": "translated", "value": "OK" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "好" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "好" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } + } + }, + "fileExplorer.download.complete.body": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تم الحفظ في %@" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Sačuvano u %@" } }, + "da": { "stringUnit": { "state": "translated", "value": "Gemt i %@" } }, + "de": { "stringUnit": { "state": "translated", "value": "Gespeichert unter %@" } }, + "es": { "stringUnit": { "state": "translated", "value": "Guardado en %@" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Enregistré dans %@" } }, + "it": { "stringUnit": { "state": "translated", "value": "Salvato in %@" } }, + "ko": { "stringUnit": { "state": "translated", "value": "%@에 저장됨" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Lagret i %@" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Zapisano w %@" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Salvo em %@" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Сохранено в %@" } }, + "th": { "stringUnit": { "state": "translated", "value": "บันทึกไปยัง %@ แล้ว" } }, + "tr": { "stringUnit": { "state": "translated", "value": "%@ konumuna kaydedildi" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Збережено в %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已保存到 %@" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "已儲存到 %@" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に保存しました" + } + } + } + }, + "fileExplorer.download.complete.title": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "اكتمل التنزيل" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Preuzimanje završeno" } }, + "da": { "stringUnit": { "state": "translated", "value": "Download fuldført" } }, + "de": { "stringUnit": { "state": "translated", "value": "Download abgeschlossen" } }, + "es": { "stringUnit": { "state": "translated", "value": "Descarga completada" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Téléchargement terminé" } }, + "it": { "stringUnit": { "state": "translated", "value": "Download completato" } }, + "ko": { "stringUnit": { "state": "translated", "value": "다운로드 완료" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Nedlasting fullført" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Pobieranie ukończone" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Download concluído" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Загрузка завершена" } }, + "th": { "stringUnit": { "state": "translated", "value": "ดาวน์โหลดเสร็จสมบูรณ์" } }, + "tr": { "stringUnit": { "state": "translated", "value": "İndirme tamamlandı" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Завантаження завершено" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载完成" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載完成" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download Complete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード完了" + } + } + } + }, + "fileExplorer.download.failed.title": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "فشل التنزيل" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Preuzimanje nije uspjelo" } }, + "da": { "stringUnit": { "state": "translated", "value": "Download mislykkedes" } }, + "de": { "stringUnit": { "state": "translated", "value": "Download fehlgeschlagen" } }, + "es": { "stringUnit": { "state": "translated", "value": "Error al descargar" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Échec du téléchargement" } }, + "it": { "stringUnit": { "state": "translated", "value": "Download non riuscito" } }, + "ko": { "stringUnit": { "state": "translated", "value": "다운로드 실패" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Nedlasting mislyktes" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Pobieranie nie powiodło się" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Falha no download" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Не удалось скачать" } }, + "th": { "stringUnit": { "state": "translated", "value": "ดาวน์โหลดไม่สำเร็จ" } }, + "tr": { "stringUnit": { "state": "translated", "value": "İndirme başarısız" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Не вдалося завантажити" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载失败" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載失敗" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードに失敗しました" + } + } + } + }, + "fileExplorer.download.panelMessage": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "اختر مكان تنزيل \"%@\"." } }, + "bs": { "stringUnit": { "state": "translated", "value": "Odaberite gdje preuzeti \"%@\"." } }, + "da": { "stringUnit": { "state": "translated", "value": "Vælg, hvor \"%@\" skal downloades." } }, + "de": { "stringUnit": { "state": "translated", "value": "Wähle aus, wohin „%@“ heruntergeladen werden soll." } }, + "es": { "stringUnit": { "state": "translated", "value": "Elige dónde descargar \"%@\"." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Choisissez où télécharger « %@ »." } }, + "it": { "stringUnit": { "state": "translated", "value": "Scegli dove scaricare \"%@\"." } }, + "ko": { "stringUnit": { "state": "translated", "value": "\"%@\"을(를) 다운로드할 위치를 선택하세요." } }, + "nb": { "stringUnit": { "state": "translated", "value": "Velg hvor «%@» skal lastes ned." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Wybierz miejsce pobrania „%@”." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Escolha onde baixar \"%@\"." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Выберите, куда скачать «%@»." } }, + "th": { "stringUnit": { "state": "translated", "value": "เลือกตำแหน่งที่จะดาวน์โหลด \"%@\"" } }, + "tr": { "stringUnit": { "state": "translated", "value": "\"%@\" öğesinin nereye indirileceğini seçin." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Виберіть, куди завантажити «%@»." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "选择“%@”的下载位置。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "選擇「%@」的下載位置。" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose where to download \"%@\"." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" のダウンロード先を選択してください。" + } + } + } + }, + "fileExplorer.download.panelPrompt": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تنزيل" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Preuzmi" } }, + "da": { "stringUnit": { "state": "translated", "value": "Download" } }, + "de": { "stringUnit": { "state": "translated", "value": "Herunterladen" } }, + "es": { "stringUnit": { "state": "translated", "value": "Descargar" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Télécharger" } }, + "it": { "stringUnit": { "state": "translated", "value": "Scarica" } }, + "ko": { "stringUnit": { "state": "translated", "value": "다운로드" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Last ned" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Pobierz" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Baixar" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Скачать" } }, + "th": { "stringUnit": { "state": "translated", "value": "ดาวน์โหลด" } }, + "tr": { "stringUnit": { "state": "translated", "value": "İndir" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Завантажити" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + } + } + }, + "fileExplorer.download.panelTitle": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تنزيل عنصر بعيد" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Preuzmi udaljenu stavku" } }, + "da": { "stringUnit": { "state": "translated", "value": "Download fjernelement" } }, + "de": { "stringUnit": { "state": "translated", "value": "Remote-Element herunterladen" } }, + "es": { "stringUnit": { "state": "translated", "value": "Descargar elemento remoto" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Télécharger l’élément distant" } }, + "it": { "stringUnit": { "state": "translated", "value": "Scarica elemento remoto" } }, + "ko": { "stringUnit": { "state": "translated", "value": "원격 항목 다운로드" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Last ned eksternt objekt" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Pobierz element zdalny" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Baixar item remoto" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Скачать удалённый элемент" } }, + "th": { "stringUnit": { "state": "translated", "value": "ดาวน์โหลดรายการระยะไกล" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Uzak öğeyi indir" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Завантажити віддалений елемент" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载远程项目" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載遠端項目" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download Remote Item" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート項目をダウンロード" + } + } + } + }, + "fileExplorer.error.downloadDestinationInvalid": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "وجهة التنزيل ليست مجلدًا محليًا: %@" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Odredište preuzimanja nije lokalna mapa: %@" } }, + "da": { "stringUnit": { "state": "translated", "value": "Downloaddestinationen er ikke en lokal mappe: %@" } }, + "de": { "stringUnit": { "state": "translated", "value": "Das Downloadziel ist kein lokaler Ordner: %@" } }, + "es": { "stringUnit": { "state": "translated", "value": "El destino de descarga no es una carpeta local: %@" } }, + "fr": { "stringUnit": { "state": "translated", "value": "La destination de téléchargement n’est pas un dossier local : %@" } }, + "it": { "stringUnit": { "state": "translated", "value": "La destinazione del download non è una cartella locale: %@" } }, + "ko": { "stringUnit": { "state": "translated", "value": "다운로드 대상이 로컬 폴더가 아닙니다: %@" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Nedlastingsmålet er ikke en lokal mappe: %@" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Miejsce docelowe pobierania nie jest folderem lokalnym: %@" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "O destino do download não é uma pasta local: %@" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Место назначения не является локальной папкой: %@" } }, + "th": { "stringUnit": { "state": "translated", "value": "ปลายทางการดาวน์โหลดไม่ใช่โฟลเดอร์ในเครื่อง: %@" } }, + "tr": { "stringUnit": { "state": "translated", "value": "İndirme hedefi yerel bir klasör değil: %@" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Місце призначення не є локальною папкою: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载目标不是本地文件夹:%@" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載目標不是本機資料夾:%@" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download destination is not a local folder: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード先はローカルフォルダではありません: %@" + } + } + } + }, + "fileExplorer.error.downloadTargetExists": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "يوجد عنصر محلي بالفعل في %@" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Lokalna stavka već postoji na %@" } }, + "da": { "stringUnit": { "state": "translated", "value": "Der findes allerede et lokalt element på %@" } }, + "de": { "stringUnit": { "state": "translated", "value": "Unter %@ ist bereits ein lokales Objekt vorhanden" } }, + "es": { "stringUnit": { "state": "translated", "value": "Ya existe un elemento local en %@" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Un élément local existe déjà à %@" } }, + "it": { "stringUnit": { "state": "translated", "value": "Esiste già un elemento locale in %@" } }, + "ko": { "stringUnit": { "state": "translated", "value": "%@에 로컬 항목이 이미 있습니다" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Det finnes allerede et lokalt objekt på %@" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Element lokalny już istnieje w %@" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Já existe um item local em %@" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Локальный объект уже существует в %@" } }, + "th": { "stringUnit": { "state": "translated", "value": "มีรายการในเครื่องอยู่แล้วที่ %@" } }, + "tr": { "stringUnit": { "state": "translated", "value": "%@ konumunda zaten yerel bir öğe var" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Локальний елемент уже існує в %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "本地项目已存在于 %@" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "本機項目已存在於 %@" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "A local item already exists at %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ には既にローカル項目があります" + } + } + } + }, + "fileExplorer.error.downloadFailed": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "فشل التنزيل: %@" } }, + "bs": { "stringUnit": { "state": "translated", "value": "Preuzimanje nije uspjelo: %@" } }, + "da": { "stringUnit": { "state": "translated", "value": "Download mislykkedes: %@" } }, + "de": { "stringUnit": { "state": "translated", "value": "Download fehlgeschlagen: %@" } }, + "es": { "stringUnit": { "state": "translated", "value": "Error al descargar: %@" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Échec du téléchargement : %@" } }, + "it": { "stringUnit": { "state": "translated", "value": "Download non riuscito: %@" } }, + "ko": { "stringUnit": { "state": "translated", "value": "다운로드 실패: %@" } }, + "nb": { "stringUnit": { "state": "translated", "value": "Nedlasting mislyktes: %@" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Pobieranie nie powiodło się: %@" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Falha no download: %@" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Не удалось скачать: %@" } }, + "th": { "stringUnit": { "state": "translated", "value": "ดาวน์โหลดไม่สำเร็จ: %@" } }, + "tr": { "stringUnit": { "state": "translated", "value": "İndirme başarısız: %@" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Не вдалося завантажити: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载失败:%@" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載失敗:%@" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードに失敗しました: %@" + } + } + } + }, + "fileExplorer.error.downloadHelperUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تعذر بدء مساعد التنزيل." } }, + "bs": { "stringUnit": { "state": "translated", "value": "Nije moguće pokrenuti pomoćnik za preuzimanje." } }, + "da": { "stringUnit": { "state": "translated", "value": "Downloadhjælperen kunne ikke startes." } }, + "de": { "stringUnit": { "state": "translated", "value": "Der Download-Helfer konnte nicht gestartet werden." } }, + "es": { "stringUnit": { "state": "translated", "value": "No se pudo iniciar el asistente de descarga." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Impossible de démarrer l’assistant de téléchargement." } }, + "it": { "stringUnit": { "state": "translated", "value": "Impossibile avviare l’assistente per il download." } }, + "ko": { "stringUnit": { "state": "translated", "value": "다운로드 도우미를 시작할 수 없습니다." } }, + "nb": { "stringUnit": { "state": "translated", "value": "Kunne ikke starte nedlastingshjelperen." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Nie można uruchomić pomocnika pobierania." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível iniciar o auxiliar de download." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Не удалось запустить помощник загрузки." } }, + "th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถเริ่มตัวช่วยดาวน์โหลดได้" } }, + "tr": { "stringUnit": { "state": "translated", "value": "İndirme yardımcısı başlatılamadı." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Не вдалося запустити помічник завантаження." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法启动下载助手。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "無法啟動下載輔助程式。" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to start the download helper." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードヘルパーを開始できません。" + } + } + } + }, + "fileExplorer.error.downloadTimedOut": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "انتهت مهلة التنزيل." } }, + "bs": { "stringUnit": { "state": "translated", "value": "Vrijeme za preuzimanje je isteklo." } }, + "da": { "stringUnit": { "state": "translated", "value": "Download fik timeout." } }, + "de": { "stringUnit": { "state": "translated", "value": "Zeitüberschreitung beim Download." } }, + "es": { "stringUnit": { "state": "translated", "value": "La descarga agotó el tiempo de espera." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Le téléchargement a expiré." } }, + "it": { "stringUnit": { "state": "translated", "value": "Il download è scaduto." } }, + "ko": { "stringUnit": { "state": "translated", "value": "다운로드 시간이 초과되었습니다." } }, + "nb": { "stringUnit": { "state": "translated", "value": "Nedlastingen ble tidsavbrutt." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Upłynął limit czasu pobierania." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "O download excedeu o tempo limite." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Время ожидания загрузки истекло." } }, + "th": { "stringUnit": { "state": "translated", "value": "การดาวน์โหลดหมดเวลา" } }, + "tr": { "stringUnit": { "state": "translated", "value": "İndirme zaman aşımına uğradı." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Час очікування завантаження минув." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载超时。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載逾時。" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download timed out." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードがタイムアウトしました。" + } + } + } + }, + "fileExplorer.error.remoteCommandExited": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "خرج الأمر البعيد بالحالة %d." } }, + "bs": { "stringUnit": { "state": "translated", "value": "Udaljena naredba je završila sa statusom %d." } }, + "da": { "stringUnit": { "state": "translated", "value": "Fjernkommandoen afsluttede med status %d." } }, + "de": { "stringUnit": { "state": "translated", "value": "Der Remote-Befehl wurde mit Status %d beendet." } }, + "es": { "stringUnit": { "state": "translated", "value": "El comando remoto salió con el estado %d." } }, + "fr": { "stringUnit": { "state": "translated", "value": "La commande distante s’est terminée avec l’état %d." } }, + "it": { "stringUnit": { "state": "translated", "value": "Il comando remoto è terminato con stato %d." } }, + "ko": { "stringUnit": { "state": "translated", "value": "원격 명령이 상태 %d로 종료되었습니다." } }, + "nb": { "stringUnit": { "state": "translated", "value": "Fjernkommandoen avsluttet med status %d." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Polecenie zdalne zakończyło się ze stanem %d." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "O comando remoto saiu com status %d." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Удалённая команда завершилась со статусом %d." } }, + "th": { "stringUnit": { "state": "translated", "value": "คำสั่งระยะไกลออกด้วยสถานะ %d" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Uzak komut %d durumuyla çıktı." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Віддалена команда завершилася зі статусом %d." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "远程命令以状态 %d 退出。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "遠端命令以狀態 %d 結束。" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote command exited with status %d." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートコマンドはステータス %d で終了しました。" + } + } + } + }, + "fileExplorer.error.sshFailed": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "فشل أمر SSH: %@" } }, + "bs": { "stringUnit": { "state": "translated", "value": "SSH naredba nije uspjela: %@" } }, + "da": { "stringUnit": { "state": "translated", "value": "SSH-kommando mislykkedes: %@" } }, + "de": { "stringUnit": { "state": "translated", "value": "SSH-Befehl fehlgeschlagen: %@" } }, + "es": { "stringUnit": { "state": "translated", "value": "Error en el comando SSH: %@" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Échec de la commande SSH : %@" } }, + "it": { "stringUnit": { "state": "translated", "value": "Comando SSH non riuscito: %@" } }, + "ko": { "stringUnit": { "state": "translated", "value": "SSH 명령 실패: %@" } }, + "nb": { "stringUnit": { "state": "translated", "value": "SSH-kommando mislyktes: %@" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Polecenie SSH nie powiodło się: %@" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Falha no comando SSH: %@" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Сбой команды SSH: %@" } }, + "th": { "stringUnit": { "state": "translated", "value": "คำสั่ง SSH ล้มเหลว: %@" } }, + "tr": { "stringUnit": { "state": "translated", "value": "SSH komutu başarısız: %@" } }, + "uk": { "stringUnit": { "state": "translated", "value": "Помилка команди SSH: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "SSH 命令失败:%@" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "SSH 命令失敗:%@" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH command failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH コマンドに失敗しました: %@" + } + } + } + }, "fileExplorer.error.unavailable": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b42ff91c488f..5a829ff67f17 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2469,6 +2469,7 @@ struct ContentView: View { // No selection means we have no local cwd to scope by; clear so the // sessions panel doesn't keep filtering by a stale previous tab. sessionIndexStore.setCurrentDirectoryIfChanged(nil) + fileExplorerStore.setDefaultLocalDownloadDirectory(nil) fileExplorerStore.applyWorkspaceRoot(.none) return } @@ -2477,6 +2478,7 @@ struct ContentView: View { if tab.isRemoteWorkspace { sessionIndexStore.setCurrentDirectoryIfChanged(nil) + fileExplorerStore.setDefaultLocalDownloadDirectory(tab.defaultLocalDownloadDirectory) guard let config = tab.remoteConfiguration, config.transport == .ssh else { fileExplorerStore.applyWorkspaceRoot(.none) return @@ -2514,11 +2516,13 @@ struct ContentView: View { let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) guard !dir.isEmpty else { sessionIndexStore.setCurrentDirectoryIfChanged(nil) + fileExplorerStore.setDefaultLocalDownloadDirectory(nil) fileExplorerStore.applyWorkspaceRoot(.none) return } sessionIndexStore.setCurrentDirectoryIfChanged(dir) + fileExplorerStore.setDefaultLocalDownloadDirectory(dir) fileExplorerStore.applyWorkspaceRoot(.local(path: dir)) } diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 5909d8c44be1..e8754f1c13a3 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import Darwin import Foundation import QuartzCore import SwiftUI @@ -275,6 +276,12 @@ protocol SSHFileExplorerTransport: AnyObject { connection: SSHFileExplorerConnection, showHidden: Bool ) async throws -> [FileExplorerEntry] + nonisolated func download( + remotePath: String, + isDirectory: Bool, + connection: SSHFileExplorerConnection, + toLocalDirectory localDirectory: String + ) async throws -> String } enum FileExplorerWorkspaceRoot: Equatable { @@ -403,168 +410,51 @@ final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable { } return try await transport.listDirectory(path: path, connection: connection, showHidden: showHidden) } -} - -final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { - static let shared = ProcessSSHFileExplorerTransport() - - nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String { - let output = try await Self.runSSHCommand( - connection: connection, - command: #"printf '%s\n' "$HOME""# - ) - return output.trimmingCharacters(in: .whitespacesAndNewlines) - } - - nonisolated func listDirectory( - path: String, - connection: SSHFileExplorerConnection, - showHidden: Bool - ) async throws -> [FileExplorerEntry] { - try await Self.runSSHListCommand(path: path, connection: connection, showHidden: showHidden) - } - - private struct SSHCommandResult: Sendable { - let stdout: String - let stderr: String - let terminationStatus: Int32 - } - - // Keeps the child process reachable from the cancellation handler while - // the blocking wait runs off Swift's cooperative executor. - private final class SSHCommandProcess: @unchecked Sendable { - private let process = Process() - private let outPipe = Pipe() - private let errPipe = Pipe() - private let lock = NSLock() - private var cancelled = false - - init(connection: SSHFileExplorerConnection, command: String) { - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = ProcessSSHFileExplorerTransport.sshArguments(connection: connection, command: command) - process.standardOutput = outPipe - process.standardError = errPipe - } - - func run() throws -> SSHCommandResult { - lock.lock() - let wasCancelled = cancelled - lock.unlock() - if wasCancelled { - throw CancellationError() - } - - try process.run() - - lock.lock() - let shouldTerminate = cancelled && process.isRunning - lock.unlock() - if shouldTerminate { - process.terminate() - } - - let data = outPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - return SSHCommandResult( - stdout: String(data: data, encoding: .utf8) ?? "", - stderr: String(data: stderrData, encoding: .utf8) ?? "", - terminationStatus: process.terminationStatus - ) - } - - func terminate() { - lock.lock() - cancelled = true - let isRunning = process.isRunning - lock.unlock() - - if isRunning { - process.terminate() - } - } - } - private static func runSSHCommand(connection: SSHFileExplorerConnection, command: String) async throws -> String { - let commandProcess = SSHCommandProcess(connection: connection, command: command) - let result = try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume(with: Result { try commandProcess.run() }) - } - } - } onCancel: { - commandProcess.terminate() - } - - guard result.terminationStatus == 0 else { - throw FileExplorerError.sshCommandFailed(result.stderr) - } - return result.stdout - } - - private static func sshArguments(connection: SSHFileExplorerConnection, command: String) -> [String] { - var args: [String] = [] - if let port = connection.port { - args += ["-p", String(port)] - } - if let identityFile = connection.identityFile { - args += ["-i", identityFile] - } - for option in connection.sshOptions { - args += ["-o", option] + func download(remotePath: String, isDirectory: Bool, toLocalDirectory localDirectory: String) async throws -> String { + guard isAvailable else { + throw FileExplorerError.providerUnavailable } - // Batch mode, no TTY, connection timeout - args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"] - args += [connection.destination, command] - return args - } - - private static func runSSHListCommand( - path: String, - connection: SSHFileExplorerConnection, - showHidden: Bool - ) async throws -> [FileExplorerEntry] { - // Escape single quotes in path for shell safety - let escapedPath = path.replacingOccurrences(of: "'", with: "'\\''") - let lsFlags = showHidden ? "-1paFA" : "-1paF" - let output = try await runSSHCommand( + return try await transport.download( + remotePath: remotePath, + isDirectory: isDirectory, connection: connection, - command: "ls \(lsFlags) '\(escapedPath)' 2>/dev/null" + toLocalDirectory: localDirectory ) - - let normalizedPath = path.hasSuffix("/") ? path : path + "/" - return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in - let entry = String(line) - // Skip . and .. entries - guard entry != "./" && entry != "../" else { return nil } - let isDir = entry.hasSuffix("/") - let name = isDir ? String(entry.dropLast()) : entry - guard showHidden || !name.hasPrefix(".") else { return nil } - // Strip type indicators from -F flag (*, @, =, |) for files - let cleanName: String - if !isDir, let last = name.last, "*@=|".contains(last) { - cleanName = String(name.dropLast()) - } else { - cleanName = name - } - let fullPath = normalizedPath + cleanName - return FileExplorerEntry(name: cleanName, path: fullPath, isDirectory: isDir) - } } } enum FileExplorerError: LocalizedError { case providerUnavailable case sshCommandFailed(String) + case invalidDownloadDestination(String) + case downloadFailed(String) + case downloadTimedOut var errorDescription: String? { switch self { case .providerUnavailable: return String(localized: "fileExplorer.error.unavailable", defaultValue: "File explorer is not available") case .sshCommandFailed(let detail): - return String(localized: "fileExplorer.error.sshFailed", defaultValue: "SSH command failed: \(detail)") + return String( + format: String(localized: "fileExplorer.error.sshFailed", defaultValue: "SSH command failed: %@"), + detail + ) + case .invalidDownloadDestination(let path): + return String( + format: String( + localized: "fileExplorer.error.downloadDestinationInvalid", + defaultValue: "Download destination is not a local folder: %@" + ), + path + ) + case .downloadFailed(let detail): + return String( + format: String(localized: "fileExplorer.error.downloadFailed", defaultValue: "Download failed: %@"), + detail + ) + case .downloadTimedOut: + return String(localized: "fileExplorer.error.downloadTimedOut", defaultValue: "Download timed out.") } } } @@ -598,6 +488,8 @@ final class FileExplorerStore: ObservableObject { /// Whether hidden files are shown. Set from FileExplorerState externally. var showHiddenFiles: Bool = false + private(set) var defaultLocalDownloadDirectory: String? + /// Watches the root directory for filesystem changes (local only). private var directoryWatcher: FileExplorerDirectoryWatcher? @@ -674,6 +566,10 @@ final class FileExplorerStore: ObservableObject { } } + func setDefaultLocalDownloadDirectory(_ path: String?) { + defaultLocalDownloadDirectory = LocalDirectoryPathNormalization.existingDirectoryPath(path) + } + func setRootPath(_ path: String) { guard path != rootPath else { #if DEBUG diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 1a02a664e698..fad4b6fb738b 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -99,6 +99,7 @@ enum FileExplorerPanelPlacement: Equatable { struct FileExplorerPanelView: NSViewRepresentable { @ObservedObject var store: FileExplorerStore @ObservedObject var state: FileExplorerState + var workspaceId: UUID? = nil let onOpenFilePreview: (String) -> Void var presentation: FileExplorerPanelPresentation = .files var placement: FileExplorerPanelPlacement = .rightSidebar @@ -109,6 +110,7 @@ struct FileExplorerPanelView: NSViewRepresentable { Coordinator( store: store, state: state, + workspaceId: workspaceId, onOpenFilePreview: onOpenFilePreview, placement: placement, onFocus: onFocus, @@ -126,6 +128,7 @@ struct FileExplorerPanelView: NSViewRepresentable { func updateNSView(_ container: FileExplorerContainerView, context: Context) { context.coordinator.store = store context.coordinator.state = state + context.coordinator.workspaceId = workspaceId context.coordinator.onOpenFilePreview = onOpenFilePreview context.coordinator.placement = placement context.coordinator.onFocus = onFocus @@ -147,6 +150,7 @@ struct FileExplorerPanelView: NSViewRepresentable { final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate, NSMenuDelegate { var store: FileExplorerStore var state: FileExplorerState + var workspaceId: UUID? var onOpenFilePreview: (String) -> Void var placement: FileExplorerPanelPlacement var onFocus: (() -> Void)? @@ -157,10 +161,13 @@ struct FileExplorerPanelView: NSViewRepresentable { private var observationCancellable: AnyCancellable? private var styleObserver: Any? private var isUpdatingOutlineProgrammatically = false + private var activeDownloadTask: Task? + private var activeDownloadID: UUID? init( store: FileExplorerStore, state: FileExplorerState, + workspaceId: UUID?, onOpenFilePreview: @escaping (String) -> Void, placement: FileExplorerPanelPlacement = .rightSidebar, onFocus: (() -> Void)? = nil, @@ -168,6 +175,7 @@ struct FileExplorerPanelView: NSViewRepresentable { ) { self.store = store self.state = state + self.workspaceId = workspaceId self.onOpenFilePreview = onOpenFilePreview self.placement = placement self.onFocus = onFocus @@ -213,6 +221,7 @@ struct FileExplorerPanelView: NSViewRepresentable { } deinit { + activeDownloadTask?.cancel() if let observer = styleObserver { NotificationCenter.default.removeObserver(observer) } @@ -653,6 +662,21 @@ struct FileExplorerPanelView: NSViewRepresentable { menu.addItem(.separator()) } + if store.provider is SSHFileExplorerProvider { + let downloadItem = NSMenuItem( + title: String( + localized: "fileExplorer.contextMenu.downloadToLocal", + defaultValue: "Download to Local…" + ), + action: #selector(contextMenuDownloadToLocal(_:)), + keyEquivalent: "" + ) + downloadItem.target = self + downloadItem.representedObject = node + menu.addItem(downloadItem) + menu.addItem(.separator()) + } + menu.addFileExplorerInsertPathItems(target: self, representedObject: node, insertAction: #selector(contextMenuInsertPath(_:)), insertRelativeAction: #selector(contextMenuInsertRelativePath(_:))) let copyPathItem = NSMenuItem( @@ -684,6 +708,28 @@ struct FileExplorerPanelView: NSViewRepresentable { NSWorkspace.shared.selectFile(node.path, inFileViewerRootedAtPath: "") } + @MainActor + @objc private func contextMenuDownloadToLocal(_ sender: NSMenuItem) { + guard let node = sender.representedObject as? FileExplorerNode, + store.provider is SSHFileExplorerProvider else { + return + } + let remotePath = node.path + let isDirectory = node.isDirectory + let itemName = node.name + + chooseDownloadDirectory(for: node) { [weak self] directoryURL in + Task { @MainActor [weak self, remotePath, isDirectory, itemName] in + self?.downloadRemoteItem( + remotePath: remotePath, + isDirectory: isDirectory, + itemName: itemName, + toLocalDirectory: directoryURL + ) + } + } + } + @objc private func contextMenuCopyPath(_ sender: NSMenuItem) { guard let node = sender.representedObject as? FileExplorerNode else { return } NSPasteboard.general.clearContents() @@ -696,6 +742,192 @@ struct FileExplorerPanelView: NSViewRepresentable { NSPasteboard.general.clearContents() NSPasteboard.general.setString(relativePath, forType: .string) } + + @MainActor + private func chooseDownloadDirectory(for node: FileExplorerNode, completion: @escaping (URL) -> Void) { + let panel = NSOpenPanel() + panel.identifier = NSUserInterfaceItemIdentifier("cmux.fileExplorerDownloadDirectory") + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.title = String( + localized: "fileExplorer.download.panelTitle", + defaultValue: "Download Remote Item" + ) + panel.prompt = String(localized: "fileExplorer.download.panelPrompt", defaultValue: "Download") + panel.message = String.localizedStringWithFormat( + String( + localized: "fileExplorer.download.panelMessage", + defaultValue: "Choose where to download \"%@\"." + ), + node.name + ) + panel.directoryURL = defaultDownloadDirectoryURL() + + let handleResponse: (NSApplication.ModalResponse) -> Void = { response in + guard response == .OK, let url = panel.url else { return } + completion(url) + } + if let window = containerView?.window { + panel.beginSheetModal(for: window, completionHandler: handleResponse) + } else { + handleResponse(panel.runModal()) + } + } + + private func defaultDownloadDirectoryURL() -> URL? { + if let path = store.defaultLocalDownloadDirectory { + return URL(fileURLWithPath: path, isDirectory: true) + } + if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { + return downloadsURL + } + return FileManager.default.homeDirectoryForCurrentUser + } + + @MainActor + private func downloadRemoteItem( + remotePath: String, + isDirectory: Bool, + itemName: String, + toLocalDirectory directoryURL: URL + ) { + guard let provider = store.provider as? SSHFileExplorerProvider else { return } + let localDirectory = directoryURL.standardizedFileURL.path + store.setDefaultLocalDownloadDirectory(localDirectory) + let workspaceId = workspaceId + let downloadID = UUID() + + activeDownloadTask?.cancel() + activeDownloadID = downloadID + activeDownloadTask = Task { [weak self] in + do { + let localPath = try await provider.download( + remotePath: remotePath, + isDirectory: isDirectory, + toLocalDirectory: localDirectory + ) + self?.completeDownloadTask( + downloadID, + itemName: itemName, + localPath: localPath, + workspaceId: workspaceId + ) + } catch is CancellationError { + self?.cancelDownloadTask(downloadID) + } catch { + self?.failDownloadTask( + downloadID, + itemName: itemName, + error: error, + workspaceId: workspaceId + ) + } + } + } + + @MainActor + private func completeDownloadTask( + _ downloadID: UUID, + itemName: String, + localPath: String, + workspaceId: UUID? + ) { + guard finishDownloadTask(downloadID) else { return } + presentDownloadCompletion( + itemName: itemName, + localPath: localPath, + workspaceId: workspaceId + ) + } + + @MainActor + private func failDownloadTask( + _ downloadID: UUID, + itemName: String, + error: Error, + workspaceId: UUID? + ) { + guard finishDownloadTask(downloadID) else { return } + presentDownloadFailure( + itemName: itemName, + error: error, + workspaceId: workspaceId + ) + } + + @MainActor + private func cancelDownloadTask(_ downloadID: UUID) { + _ = finishDownloadTask(downloadID) + } + + @MainActor + private func finishDownloadTask(_ downloadID: UUID) -> Bool { + guard activeDownloadID == downloadID else { return false } + activeDownloadID = nil + activeDownloadTask = nil + return true + } + + @MainActor + private func presentDownloadCompletion(itemName: String, localPath: String, workspaceId: UUID?) { + let title = String(localized: "fileExplorer.download.complete.title", defaultValue: "Download Complete") + let body = String.localizedStringWithFormat( + String(localized: "fileExplorer.download.complete.body", defaultValue: "Saved to %@"), + localPath + ) + + if let workspaceId { + AppDelegate.shared?.notificationStore?.addNotification( + tabId: workspaceId, + surfaceId: nil, + title: title, + subtitle: itemName, + body: body + ) + return + } + + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title + alert.informativeText = body + alert.addButton(withTitle: String(localized: "fileExplorer.download.alertOK", defaultValue: "OK")) + if let window = containerView?.window { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() + } + } + + @MainActor + private func presentDownloadFailure(itemName: String, error: Error, workspaceId: UUID?) { + if let workspaceId { + AppDelegate.shared?.notificationStore?.addNotification( + tabId: workspaceId, + surfaceId: nil, + title: String(localized: "fileExplorer.download.failed.title", defaultValue: "Download Failed"), + subtitle: itemName, + body: error.localizedDescription + ) + return + } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "fileExplorer.download.failed.title", + defaultValue: "Download Failed" + ) + alert.informativeText = error.localizedDescription + alert.addButton(withTitle: String(localized: "fileExplorer.download.alertOK", defaultValue: "OK")) + if let window = containerView?.window { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() + } + } } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 669f7203b82b..7b7c27a6a3ef 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -393,7 +393,7 @@ enum GhosttyPasteboardHelper { static func escapeForShell(_ value: String) -> String { if value.contains(where: { $0 == "\n" || $0 == "\r" }) { - return shellSingleQuoted(value) + return ShellArgumentQuoting.singleQuoted(value) } var result = value for char in shellEscapeCharacters { @@ -402,11 +402,6 @@ enum GhosttyPasteboardHelper { return result } - private static func shellSingleQuoted(_ value: String) -> String { - let escaped = value.replacingOccurrences(of: "'", with: "'\\''") - return "'\(escaped)'" - } - private static func attributedStringContents( from pasteboard: NSPasteboard, type: NSPasteboard.PasteboardType, diff --git a/Sources/LocalDirectoryPathNormalization.swift b/Sources/LocalDirectoryPathNormalization.swift new file mode 100644 index 000000000000..0d95306c632d --- /dev/null +++ b/Sources/LocalDirectoryPathNormalization.swift @@ -0,0 +1,21 @@ +import Foundation + +enum LocalDirectoryPathNormalization { + static func existingDirectoryPath( + _ path: String?, + fileManager: FileManager = .default + ) -> String? { + guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + + let normalizedPath = (path as NSString).expandingTildeInPath + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + return normalizedPath + } +} diff --git a/Sources/ProcessSSHFileExplorerTransport.swift b/Sources/ProcessSSHFileExplorerTransport.swift new file mode 100644 index 000000000000..d78c2a31e36f --- /dev/null +++ b/Sources/ProcessSSHFileExplorerTransport.swift @@ -0,0 +1,588 @@ +import Darwin +import Foundation + +final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { + static let shared = ProcessSSHFileExplorerTransport() + + nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String { + let output = try await Self.runSSHCommand( + connection: connection, + command: #"printf '%s\n' "$HOME""# + ) + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + nonisolated func listDirectory( + path: String, + connection: SSHFileExplorerConnection, + showHidden: Bool + ) async throws -> [FileExplorerEntry] { + try await Self.runSSHListCommand(path: path, connection: connection, showHidden: showHidden) + } + + nonisolated func download( + remotePath: String, + isDirectory: Bool, + connection: SSHFileExplorerConnection, + toLocalDirectory localDirectory: String + ) async throws -> String { + try await Self.runSCPDownloadCommand( + remotePath: remotePath, + isDirectory: isDirectory, + connection: connection, + localDirectory: localDirectory + ) + } + + private struct SSHCommandResult: Sendable { + let stdout: String + let stderr: String + let terminationStatus: Int32 + } + + // Shared with termination/readability callbacks; mutable process state is + // guarded by lock and output/waiter helpers have their own locks. + private final class CommandProcess: @unchecked Sendable { + // FileHandle readability callbacks can run off the creating task; all + // mutable output buffers are protected by lock. + private final class OutputCollector: @unchecked Sendable { + private enum Stream { + case stdout + case stderr + } + + private let stdoutHandle: FileHandle + private let stderrHandle: FileHandle + private let lock = NSLock() + private var stdout = Data() + private var stderr = Data() + private var isFinished = false + + init(stdout: Pipe, stderr: Pipe) { + stdoutHandle = stdout.fileHandleForReading + stderrHandle = stderr.fileHandleForReading + } + + func start() { + stdoutHandle.readabilityHandler = { [weak self] handle in + self?.appendAvailableData(from: handle, to: .stdout) + } + stderrHandle.readabilityHandler = { [weak self] handle in + self?.appendAvailableData(from: handle, to: .stderr) + } + } + + func finish() -> (stdout: String, stderr: String) { + lock.lock() + guard !isFinished else { + let output = outputLocked() + lock.unlock() + return output + } + isFinished = true + lock.unlock() + + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + append(stdoutHandle.readDataToEndOfFile(), to: .stdout) + append(stderrHandle.readDataToEndOfFile(), to: .stderr) + + lock.lock() + let output = outputLocked() + lock.unlock() + return output + } + + func cancel() { + lock.lock() + guard !isFinished else { + lock.unlock() + return + } + isFinished = true + lock.unlock() + + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + } + + private func appendAvailableData(from handle: FileHandle, to stream: Stream) { + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + append(data, to: stream) + } + + private func append(_ data: Data, to stream: Stream) { + guard !data.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + switch stream { + case .stdout: + stdout.append(data) + case .stderr: + stderr.append(data) + } + } + + private func outputLocked() -> (stdout: String, stderr: String) { + ( + String(data: stdout, encoding: .utf8) ?? "", + String(data: stderr, encoding: .utf8) ?? "" + ) + } + } + + // Process.terminationHandler resumes continuations from a callback + // thread; status and continuation storage are protected by lock. + private final class TerminationWaiter: @unchecked Sendable { + private let lock = NSLock() + private var status: Int32? + private var continuations: [CheckedContinuation] = [] + + func wait() async -> Int32 { + await withCheckedContinuation { continuation in + lock.lock() + if let status { + lock.unlock() + continuation.resume(returning: status) + return + } + continuations.append(continuation) + lock.unlock() + } + } + + func finish(status: Int32) { + lock.lock() + if self.status != nil { + lock.unlock() + return + } + self.status = status + let continuations = self.continuations + self.continuations.removeAll() + lock.unlock() + + for continuation in continuations { + continuation.resume(returning: status) + } + } + } + + private let process = Process() + private let outPipe = Pipe() + private let errPipe = Pipe() + private let lock = NSLock() + private var cancelled = false + private var started = false + + init(executable: String, arguments: [String]) { + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = outPipe + process.standardError = errPipe + } + + func run(timeout: TimeInterval? = nil) async throws -> SSHCommandResult { + if isCancelled { + throw CancellationError() + } + + let outputCollector = OutputCollector(stdout: outPipe, stderr: errPipe) + let terminationWaiter = TerminationWaiter() + process.terminationHandler = { terminatedProcess in + terminationWaiter.finish(status: terminatedProcess.terminationStatus) + } + outputCollector.start() + + do { + try process.run() + } catch { + outputCollector.cancel() + process.terminationHandler = nil + throw error + } + + if markStartedAndShouldTerminate { + process.terminate() + scheduleForceKillAfterGraceUnlessTerminated(waiter: terminationWaiter) + } + + let terminationStatus: Int32 + do { + terminationStatus = try await withTaskCancellationHandler { + try await waitForTermination(timeout: timeout, waiter: terminationWaiter) + } onCancel: { + self.terminate() + self.scheduleForceKillAfterGraceUnlessTerminated(waiter: terminationWaiter) + } + } catch { + if case FileExplorerError.downloadTimedOut = error { + _ = await terminationWaiter.wait() + } else { + terminate() + await forceKillAfterGraceUnlessTerminated(waiter: terminationWaiter) + _ = await terminationWaiter.wait() + } + _ = outputCollector.finish() + process.terminationHandler = nil + throw error + } + let output = outputCollector.finish() + process.terminationHandler = nil + try Task.checkCancellation() + + return SSHCommandResult( + stdout: output.stdout, + stderr: output.stderr, + terminationStatus: terminationStatus + ) + } + + func terminate() { + lock.lock() + cancelled = true + let shouldTerminate = started && process.isRunning + lock.unlock() + + if shouldTerminate { + process.terminate() + } + } + + private var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + private var markStartedAndShouldTerminate: Bool { + lock.lock() + started = true + let shouldTerminate = cancelled && process.isRunning + lock.unlock() + return shouldTerminate + } + + private func terminateForTimeout() { + lock.lock() + let shouldTerminate = started && process.isRunning + lock.unlock() + + if shouldTerminate { + process.terminate() + } + } + + private func forceKillIfRunning() { + lock.lock() + let shouldKill = started && process.isRunning + let processIdentifier = process.processIdentifier + lock.unlock() + + if shouldKill { + _ = Darwin.kill(processIdentifier, SIGKILL) + } + } + + private enum TerminationRaceResult: Sendable { + case terminated(Int32) + case timedOut + } + + private func scheduleForceKillAfterGraceUnlessTerminated(waiter: TerminationWaiter) { + _ = Task.detached(priority: .utility) { [self, waiter] in + await forceKillAfterGraceUnlessTerminated(waiter: waiter) + } + } + + private func waitForTermination(timeout: TimeInterval?, waiter: TerminationWaiter) async throws -> Int32 { + guard let timeout else { + return await waiter.wait() + } + + let timeoutNanoseconds = UInt64(max(0, timeout) * 1_000_000_000) + return try await withThrowingTaskGroup(of: TerminationRaceResult.self) { group in + group.addTask { [waiter] in + .terminated(await waiter.wait()) + } + group.addTask { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + return .timedOut + } + + do { + guard let result = try await group.next() else { + throw FileExplorerError.downloadTimedOut + } + group.cancelAll() + switch result { + case .terminated(let status): + return status + case .timedOut: + terminateForTimeout() + await forceKillAfterGraceUnlessTerminated(waiter: waiter) + throw FileExplorerError.downloadTimedOut + } + } catch { + group.cancelAll() + throw error + } + } + } + + private func forceKillAfterGraceUnlessTerminated(waiter: TerminationWaiter) async { + // This deadline must survive parent task cancellation; otherwise a + // cancelled SCP task can SIGTERM and then wait forever if scp ignores it. + let deadlineTask = Task.detached(priority: .utility) { [self] in + do { + try await ContinuousClock().sleep(for: .seconds(1)) + } catch { + return + } + forceKillIfRunning() + } + + await withTaskGroup(of: Void.self) { group in + group.addTask { [waiter] in + _ = await waiter.wait() + } + group.addTask { + await deadlineTask.value + } + + _ = await group.next() + deadlineTask.cancel() + group.cancelAll() + } + } + } + + private static func runSSHCommand(connection: SSHFileExplorerConnection, command: String) async throws -> String { + let commandProcess = CommandProcess( + executable: "/usr/bin/ssh", + arguments: sshArguments(connection: connection, command: command) + ) + let result = try await withTaskCancellationHandler { + try await commandProcess.run() + } onCancel: { + commandProcess.terminate() + } + + guard result.terminationStatus == 0 else { + throw FileExplorerError.sshCommandFailed( + remoteCommandFailureDetail(status: result.terminationStatus) + ) + } + return result.stdout + } + + private static func runSCPCommand(arguments: [String], timeout: TimeInterval) async throws -> SSHCommandResult { + let commandProcess = CommandProcess(executable: scpExecutablePath, arguments: arguments) + do { + return try await withTaskCancellationHandler { + try await commandProcess.run(timeout: timeout) + } onCancel: { + commandProcess.terminate() + } + } catch let error as CancellationError { + throw error + } catch let error as FileExplorerError { + throw error + } catch { + throw FileExplorerError.downloadFailed(downloadHelperLaunchFailureDetail()) + } + } + + private static func sshArguments(connection: SSHFileExplorerConnection, command: String) -> [String] { + var args: [String] = [] + if let port = connection.port { + args += ["-p", String(port)] + } + if let identityFile = connection.identityFile { + args += ["-i", identityFile] + } + for option in connection.sshOptions { + args += ["-o", option] + } + // Batch mode, no TTY, connection timeout + args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"] + args += [connection.destination, command] + return args + } + + private static func runSSHListCommand( + path: String, + connection: SSHFileExplorerConnection, + showHidden: Bool + ) async throws -> [FileExplorerEntry] { + let lsFlags = showHidden ? "-1paFA" : "-1paF" + let output = try await runSSHCommand( + connection: connection, + command: "ls \(lsFlags) \(ShellArgumentQuoting.singleQuoted(path)) 2>/dev/null" + ) + + let normalizedPath = path.hasSuffix("/") ? path : path + "/" + return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in + let entry = String(line) + // Skip . and .. entries + guard entry != "./" && entry != "../" else { return nil } + let isDir = entry.hasSuffix("/") + let name = isDir ? String(entry.dropLast()) : entry + guard showHidden || !name.hasPrefix(".") else { return nil } + // Strip type indicators from -F flag (*, @, =, |) for files + let cleanName: String + if !isDir, let last = name.last, "*@=|".contains(last) { + cleanName = String(name.dropLast()) + } else { + cleanName = name + } + let fullPath = normalizedPath + cleanName + return FileExplorerEntry(name: cleanName, path: fullPath, isDirectory: isDir) + } + } + + private static func runSCPDownloadCommand( + remotePath: String, + isDirectory: Bool, + connection: SSHFileExplorerConnection, + localDirectory: String + ) async throws -> String { + let target = try downloadTarget(remotePath: remotePath, localDirectory: localDirectory) + let args = scpDownloadArguments( + remotePath: remotePath, + isDirectory: isDirectory, + connection: connection, + localDestinationPath: target.path + ) + let result = try await runSCPCommand(arguments: args, timeout: 30 * 60) + guard result.terminationStatus == 0 else { + throw FileExplorerError.downloadFailed( + remoteCommandFailureDetail(status: result.terminationStatus) + ) + } + return target.path + } + + private struct DownloadTarget { + let path: String + } + + private static func downloadTarget(remotePath: String, localDirectory: String) throws -> DownloadTarget { + let normalizedDirectory = (localDirectory as NSString).expandingTildeInPath + var isLocalDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: normalizedDirectory, isDirectory: &isLocalDirectory), + isLocalDirectory.boolValue else { + throw FileExplorerError.invalidDownloadDestination(normalizedDirectory) + } + + let targetName = (remotePath as NSString).lastPathComponent + let targetPath = (normalizedDirectory as NSString).appendingPathComponent( + targetName.isEmpty ? "download" : targetName + ) + if FileManager.default.fileExists(atPath: targetPath) { + let detail = String.localizedStringWithFormat( + String( + localized: "fileExplorer.error.downloadTargetExists", + defaultValue: "A local item already exists at %@" + ), + targetPath + ) + throw FileExplorerError.downloadFailed(detail) + } + + return DownloadTarget(path: targetPath) + } + + private static func scpDownloadArguments( + remotePath: String, + isDirectory: Bool, + connection: SSHFileExplorerConnection, + localDestinationPath: String + ) -> [String] { + var args: [String] = [ + "-q", + "-O", + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + ] + if isDirectory { + args.append("-r") + } + if let port = connection.port { + args += ["-P", String(port)] + } + if let identityFile = connection.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + let effectiveSSHOptions = SSHCommandArgumentSupport.backgroundOptions(connection.sshOptions) + if !SSHCommandArgumentSupport.hasOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + + args += [ + "\(SSHCommandArgumentSupport.scpRemoteDestination(connection.destination)):\(ShellArgumentQuoting.singleQuoted(remotePath))", + localDestinationPath, + ] + return args + } + + private static var scpExecutablePath: String { +#if DEBUG + if let scpExecutablePathOverrideForTesting { + return scpExecutablePathOverrideForTesting + } +#endif + return "/usr/bin/scp" + } + + private static func remoteCommandFailureDetail(status: Int32) -> String { + String.localizedStringWithFormat( + String( + localized: "fileExplorer.error.remoteCommandExited", + defaultValue: "Remote command exited with status %d." + ), + Int(status) + ) + } + + private static func downloadHelperLaunchFailureDetail() -> String { + String( + localized: "fileExplorer.error.downloadHelperUnavailable", + defaultValue: "Unable to start the download helper." + ) + } + +#if DEBUG + nonisolated(unsafe) static var scpExecutablePathOverrideForTesting: String? + + static func downloadTargetPathForTesting(remotePath: String, localDirectory: String) throws -> String { + try downloadTarget(remotePath: remotePath, localDirectory: localDirectory).path + } + + static func scpDownloadArgumentsForTesting( + remotePath: String, + isDirectory: Bool, + connection: SSHFileExplorerConnection, + localDestinationPath: String + ) -> [String] { + scpDownloadArguments( + remotePath: remotePath, + isDirectory: isDirectory, + connection: connection, + localDestinationPath: localDestinationPath + ) + } +#endif +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 6847f3c927cf..cf52b03887bc 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1,10 +1,6 @@ import Foundation import CMUXAgentLaunch -fileprivate func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" -} - enum AgentResumeCommandBuilder { private static let claudeAuthSelectionEnvironmentKeys: Set = [ "ANTHROPIC_API_KEY", @@ -92,12 +88,12 @@ enum AgentResumeCommandBuilder { } commandParts.append(contentsOf: argv) - var shellCommand = commandParts.map(shellSingleQuoted).joined(separator: " ") + var shellCommand = commandParts.map(ShellArgumentQuoting.singleQuoted).joined(separator: " ") let cwd = !includeWorkingDirectoryPrefix || customRegistration?.cwd == .ignore ? nil : normalized(workingDirectory ?? launchCommand?.workingDirectory) if let cwd { - shellCommand = "cd \(shellSingleQuoted(cwd)) && \(shellCommand)" + shellCommand = "cd \(ShellArgumentQuoting.singleQuoted(cwd)) && \(shellCommand)" } return shellCommand } @@ -560,7 +556,7 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { return nil } - let scriptInput = "/bin/zsh \(shellSingleQuoted(scriptURL.path))\n" + let scriptInput = "/bin/zsh \(ShellArgumentQuoting.singleQuoted(scriptURL.path))\n" return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil } } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index b724ef2fcf00..8aec2d981443 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -369,6 +369,7 @@ struct RightSidebarPanelView: View { FileExplorerPanelView( store: fileExplorerStore, state: fileExplorerState, + workspaceId: workspaceId, onOpenFilePreview: onOpenFilePreview, presentation: .files ) @@ -376,6 +377,7 @@ struct RightSidebarPanelView: View { FileExplorerPanelView( store: fileExplorerStore, state: fileExplorerState, + workspaceId: workspaceId, onOpenFilePreview: onOpenFilePreview, presentation: .find ) diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 58da6fa5a407..f8daff1422b9 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -58,6 +58,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { var displayTitle: String { mode.label } var displayIcon: String? { mode.symbolName } + var workspaceId: UUID? { workspace?.id } func reattach(to workspace: Workspace) { self.workspace = workspace @@ -246,6 +247,7 @@ struct RightSidebarToolPanelView: View { FileExplorerPanelView( store: panel.fileExplorerStore, state: panel.fileExplorerState, + workspaceId: panel.workspaceId, onOpenFilePreview: panel.openFilePreview, presentation: .files, placement: .pane, @@ -256,6 +258,7 @@ struct RightSidebarToolPanelView: View { FileExplorerPanelView( store: panel.fileExplorerStore, state: panel.fileExplorerState, + workspaceId: panel.workspaceId, onOpenFilePreview: panel.openFilePreview, presentation: .find, placement: .pane, diff --git a/Sources/SSHCommandArgumentSupport.swift b/Sources/SSHCommandArgumentSupport.swift new file mode 100644 index 000000000000..b0a20a655cb9 --- /dev/null +++ b/Sources/SSHCommandArgumentSupport.swift @@ -0,0 +1,97 @@ +import Foundation + +enum SSHCommandArgumentSupport { + private static let backgroundControlOptionKeys: Set = [ + "controlmaster", + "controlpersist", + ] + + static func normalizedOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + static func backgroundOptions(_ options: [String]) -> [String] { + normalizedOptions(options).filter { option in + guard let key = optionKey(option) else { return false } + return !backgroundControlOptionKeys.contains(key) + } + } + + static func hasOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + return options.contains { optionKey($0) == loweredKey } + } + + static func optionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + static func optionValue(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split( + maxSplits: 1, + whereSeparator: { $0 == "=" || $0.isWhitespace } + ) + guard parts.count == 2 else { return nil } + let value = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + static func optionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in normalizedOptions(options) { + guard optionKey(option) == loweredKey, + let value = optionValue(option) else { + continue + } + return value + } + return nil + } + + static func scpRemoteDestination(_ destination: String) -> String { + let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedDestination.isEmpty else { return destination } + + let parts = trimmedDestination.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + let userPart: String? + let hostPart: String + if parts.count == 2 { + userPart = String(parts[0]) + hostPart = String(parts[1]) + } else { + userPart = nil + hostPart = trimmedDestination + } + + guard shouldBracketIPv6Literal(hostPart) else { + return trimmedDestination + } + + let bracketedHost = "[\(hostPart)]" + if let userPart { + return "\(userPart)@\(bracketedHost)" + } + return bracketedHost + } + + static func shouldBracketIPv6Literal(_ host: String) -> Bool { + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmedHost.isEmpty && + trimmedHost.contains(":") && + !trimmedHost.hasPrefix("[") && + !trimmedHost.hasSuffix("]") + } +} diff --git a/Sources/SessionRestoredTerminalCommandStore.swift b/Sources/SessionRestoredTerminalCommandStore.swift index b79567a305a9..2fd723f96234 100644 --- a/Sources/SessionRestoredTerminalCommandStore.swift +++ b/Sources/SessionRestoredTerminalCommandStore.swift @@ -26,9 +26,9 @@ enum SessionRestoredTerminalCommandStore { "rm -f -- \"$0\" 2>/dev/null || true" ] if let workingDirectory = normalized(workingDirectory) { - lines.append("cd -- \(shellSingleQuoted(workingDirectory)) || exit $?") + lines.append("cd -- \(ShellArgumentQuoting.singleQuoted(workingDirectory)) || exit $?") } - lines.append("exec \"${SHELL:-/bin/zsh}\" -lc \(shellSingleQuoted(trimmedCommand))") + lines.append("exec \"${SHELL:-/bin/zsh}\" -lc \(ShellArgumentQuoting.singleQuoted(trimmedCommand))") try (lines.joined(separator: "\n") + "\n").write( to: scriptURL, @@ -50,7 +50,4 @@ enum SessionRestoredTerminalCommandStore { return trimmed } - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } } diff --git a/Sources/ShellArgumentQuoting.swift b/Sources/ShellArgumentQuoting.swift new file mode 100644 index 000000000000..57d77c3f25e7 --- /dev/null +++ b/Sources/ShellArgumentQuoting.swift @@ -0,0 +1,8 @@ +import Foundation + +enum ShellArgumentQuoting { + static func singleQuoted(_ value: String) -> String { + let escapedValue = value.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escapedValue)'" + } +} diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index ea111a35a3aa..5e228aae282c 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -157,17 +157,17 @@ struct DetectedSSHSession: Equatable { } if let controlPath, !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !Self.hasSSHOptionKey(sshOptions, key: "ControlPath") { + !SSHCommandArgumentSupport.hasOptionKey(sshOptions, key: "ControlPath") { args += ["-o", "ControlPath=\(controlPath)"] } - if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") { + if !SSHCommandArgumentSupport.hasOptionKey(sshOptions, key: "StrictHostKeyChecking") { args += ["-o", "StrictHostKeyChecking=accept-new"] } for option in sshOptions { args += ["-o", option] } - args += [localPath, "\(Self.scpRemoteDestination(destination)):\(remotePath)"] + args += [localPath, "\(SSHCommandArgumentSupport.scpRemoteDestination(destination)):\(remotePath)"] return args } @@ -206,10 +206,10 @@ struct DetectedSSHSession: Equatable { } if let controlPath, !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !Self.hasSSHOptionKey(sshOptions, key: "ControlPath") { + !SSHCommandArgumentSupport.hasOptionKey(sshOptions, key: "ControlPath") { args += ["-o", "ControlPath=\(controlPath)"] } - if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") { + if !SSHCommandArgumentSupport.hasOptionKey(sshOptions, key: "StrictHostKeyChecking") { args += ["-o", "StrictHostKeyChecking=accept-new"] } for option in sshOptions { @@ -222,8 +222,8 @@ struct DetectedSSHSession: Equatable { private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" + let cleanupScript = "rm -f -- " + remotePaths.map(ShellArgumentQuoting.singleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(ShellArgumentQuoting.singleQuoted(cleanupScript))" _ = try? Self.runProcess( executable: "/usr/bin/ssh", arguments: sshArguments(command: cleanupCommand), @@ -331,59 +331,6 @@ struct DetectedSSHSession: Equatable { .first(where: { !$0.isEmpty }) } - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - return options.contains { optionKey($0) == loweredKey } - } - - private static func optionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - private static func scpRemoteDestination(_ destination: String) -> String { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDestination.isEmpty else { return destination } - - let parts = trimmedDestination.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) - let userPart: String? - let hostPart: String - if parts.count == 2 { - userPart = String(parts[0]) - hostPart = String(parts[1]) - } else { - userPart = nil - hostPart = trimmedDestination - } - - guard shouldBracketIPv6Literal(hostPart) else { - return trimmedDestination - } - - let bracketedHost = "[\(hostPart)]" - if let userPart { - return "\(userPart)@\(bracketedHost)" - } - return bracketedHost - } - - private static func shouldBracketIPv6Literal(_ host: String) -> Bool { - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - return !trimmedHost.isEmpty && - trimmedHost.contains(":") && - !trimmedHost.hasPrefix("[") && - !trimmedHost.hasSuffix("]") - } - - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - #if DEBUG func scpArgumentsForTesting(localPath: String, remotePath: String) -> [String] { scpArguments(localPath: localPath, remotePath: remotePath) @@ -727,8 +674,8 @@ enum TerminalSSHSessionDetector { ) -> Bool { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } - let key = sshOptionKey(trimmed) - let value = sshOptionValue(trimmed) + let key = SSHCommandArgumentSupport.optionKey(trimmed) + let value = SSHCommandArgumentSupport.optionValue(trimmed) switch key { case "port": @@ -780,28 +727,4 @@ enum TerminalSSHSessionDetector { return "\(loginName)@\(trimmedDestination)" } - private static func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - private static func sshOptionValue(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if let equalIndex = trimmed.firstIndex(of: "=") { - let value = trimmed[trimmed.index(after: equalIndex)...].trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - - let parts = trimmed.split(maxSplits: 1, whereSeparator: \.isWhitespace) - guard parts.count == 2 else { return nil } - let value = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 27371b3a53f6..e1eb7eb2d1ca 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2235,10 +2235,6 @@ private final class WorkspaceRemoteDaemonRPCClient { return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" } - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - private static func bestErrorLine(stderr: String) -> String? { let lines = stderr .split(separator: "\n") @@ -4633,7 +4629,7 @@ final class WorkspaceRemoteSessionController { bootstrapRemoteTTYFetchInFlight = true defer { bootstrapRemoteTTYFetchInFlight = false } - let command = "sh -c \(Self.shellSingleQuoted("tty_path=\"$HOME/.cmux/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted("tty_path=\"$HOME/.cmux/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" do { let result = try sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], @@ -4748,16 +4744,16 @@ final class WorkspaceRemoteSessionController { private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { if batchMode { - return backgroundSSHOptions(configuration.sshOptions) + return SSHCommandArgumentSupport.backgroundOptions(configuration.sshOptions) } - return normalizedSSHOptions(configuration.sshOptions) + return SSHCommandArgumentSupport.normalizedOptions(configuration.sshOptions) }() var args: [String] = [ "-o", "ConnectTimeout=6", "-o", "ServerAliveInterval=20", "-o", "ServerAliveCountMax=2", ] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + if !SSHCommandArgumentSupport.hasOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { args += ["-o", "StrictHostKeyChecking=accept-new"] } if batchMode { @@ -4777,46 +4773,6 @@ final class WorkspaceRemoteSessionController { return args } - private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - let token = sshOptionKey(option) - if token == loweredKey { - return true - } - } - return false - } - - private func normalizedSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private func backgroundSSHOptions(_ options: [String]) -> [String] { - let batchSSHControlOptionKeys: Set = [ - "controlmaster", - "controlpersist", - ] - return normalizedSSHOptions(options).filter { option in - guard let key = sshOptionKey(option) else { return false } - return !batchSSHControlOptionKeys.contains(key) - } - } - - private func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { try runProcess( executable: "/usr/bin/ssh", @@ -5065,7 +5021,7 @@ final class WorkspaceRemoteSessionController { relayID: relayID, relayToken: relayToken ) - let command = "sh -c \(Self.shellSingleQuoted(script))" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" @@ -5084,7 +5040,7 @@ final class WorkspaceRemoteSessionController { return } let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) - let command = "sh -c \(Self.shellSingleQuoted(script))" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted(script))" do { _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) } catch { @@ -5127,7 +5083,7 @@ final class WorkspaceRemoteSessionController { printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' fi """ - let command = "sh -c \(Self.shellSingleQuoted(script))" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) let lines = result.stdout @@ -5421,8 +5377,8 @@ final class WorkspaceRemoteSessionController { "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" ) - let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" - let mkdirCommand = "sh -c \(Self.shellSingleQuoted(mkdirScript))" + let mkdirScript = "mkdir -p \(ShellArgumentQuoting.singleQuoted(remoteDirectory))" + let mkdirCommand = "sh -c \(ShellArgumentQuoting.singleQuoted(mkdirScript))" let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) guard mkdirResult.status == 0 else { let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" @@ -5431,9 +5387,9 @@ final class WorkspaceRemoteSessionController { ]) } - let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) + let scpSSHOptions = SSHCommandArgumentSupport.backgroundOptions(configuration.sshOptions) var scpArgs: [String] = ["-q"] - if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { + if !SSHCommandArgumentSupport.hasOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] } scpArgs += ["-o", "ControlMaster=no"] @@ -5457,10 +5413,10 @@ final class WorkspaceRemoteSessionController { } let finalizeScript = """ - chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \ - mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath)) + chmod 755 \(ShellArgumentQuoting.singleQuoted(remoteTempPath)) && \ + mv \(ShellArgumentQuoting.singleQuoted(remoteTempPath)) \(ShellArgumentQuoting.singleQuoted(remotePath)) """ - let finalizeCommand = "sh -c \(Self.shellSingleQuoted(finalizeScript))" + let finalizeCommand = "sh -c \(ShellArgumentQuoting.singleQuoted(finalizeScript))" let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) guard finalizeResult.status == 0 else { let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" @@ -5476,7 +5432,7 @@ final class WorkspaceRemoteSessionController { ) throws -> [String] { guard !fileURLs.isEmpty else { return [] } - let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) + let scpSSHOptions = SSHCommandArgumentSupport.backgroundOptions(configuration.sshOptions) var uploadedRemotePaths: [String] = [] do { for localURL in fileURLs { @@ -5489,7 +5445,7 @@ final class WorkspaceRemoteSessionController { let remotePath = Self.remoteDropPath(for: normalizedLocalURL) uploadedRemotePaths.append(remotePath) var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] - if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { + if !SSHCommandArgumentSupport.hasOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] } if let port = configuration.port { @@ -5526,8 +5482,8 @@ final class WorkspaceRemoteSessionController { private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" + let cleanupScript = "rm -f -- " + remotePaths.map(ShellArgumentQuoting.singleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(ShellArgumentQuoting.singleQuoted(cleanupScript))" _ = try? sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], timeout: 8 @@ -5536,8 +5492,8 @@ final class WorkspaceRemoteSessionController { private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { let request = #"{"id":1,"method":"hello","params":{}}"# - let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(Self.shellSingleQuoted(script))" + let script = "printf '%s\\n' \(ShellArgumentQuoting.singleQuoted(request)) | \(ShellArgumentQuoting.singleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" @@ -5601,7 +5557,7 @@ final class WorkspaceRemoteSessionController { private func debugShellCommand(executable: String, arguments: [String]) -> String { ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) - .map(Self.shellSingleQuoted) + .map(ShellArgumentQuoting.singleQuoted) .joined(separator: " ") } @@ -5631,10 +5587,6 @@ final class WorkspaceRemoteSessionController { return String(normalized.prefix(limit)) + "..." } - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - static func remoteCLIWrapperScript() -> String { """ #!/bin/sh @@ -5820,7 +5772,7 @@ final class WorkspaceRemoteSessionController { private static func remoteDaemonPathShellExpression(_ remotePath: String) -> String { let trimmedRemotePath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedRemotePath.hasPrefix("/") { - return shellSingleQuoted(trimmedRemotePath) + return ShellArgumentQuoting.singleQuoted(trimmedRemotePath) } return "\"$HOME/\(trimmedRemotePath)\"" } @@ -6289,7 +6241,7 @@ final class WorkspaceRemoteSessionController { let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() guard !ttyNames.isEmpty else { return [:] } - let command = "sh -c \(Self.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" let result = try sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8 @@ -6383,7 +6335,7 @@ final class WorkspaceRemoteSessionController { return } - let command = "sh -c \(Self.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" do { let result = try sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], @@ -7173,6 +7125,7 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String @Published private(set) var surfaceTabBarDirectory: String? + private let initialLocalDirectory: String private(set) var preferredBrowserProfileID: UUID? /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) @@ -7449,6 +7402,10 @@ final class Workspace: Identifiable, ObservableObject { set { panelDirectories = newValue } } + var defaultLocalDownloadDirectory: String? { + LocalDirectoryPathNormalization.existingDirectoryPath(initialLocalDirectory) + } + private var processTitle: String enum SurfaceKind { @@ -7779,6 +7736,7 @@ final class Workspace: Identifiable, ObservableObject { let initialDirectory = hasWorkingDirectory ? trimmedWorkingDirectory : FileManager.default.homeDirectoryForCurrentUser.path + self.initialLocalDirectory = initialDirectory self.currentDirectory = hasWorkingDirectory ? trimmedWorkingDirectory : FileManager.default.homeDirectoryForCurrentUser.path diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 8914541630a0..5fb1951ce2e7 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -8,11 +8,8 @@ private enum WorkspaceRemoteSSHOptionFilter { ] static func durableOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }.filter { option in - guard let key = optionKey(option) else { return true } + SSHCommandArgumentSupport.normalizedOptions(options).filter { option in + guard let key = SSHCommandArgumentSupport.optionKey(option) else { return true } return !transientControlSocketKeys.contains(key) } } @@ -29,22 +26,6 @@ private enum WorkspaceRemoteSSHOptionFilter { return normalizedOptional((trimmed as NSString).expandingTildeInPath) ?? trimmed } - static func hasOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - return options.contains { option in - optionKey(option) == loweredKey - } - } - - private static func optionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } } nonisolated enum WorkspaceRemoteTransport: String, Codable, Equatable, Sendable { @@ -176,7 +157,7 @@ extension SessionRemoteWorkspaceSnapshot { destination: normalizedDestination, port: normalizedPort, identityFile: Self.normalizedIdentityPath(identityFile), - sshOptions: Self.normalizedSSHOptions(sshOptions), + sshOptions: WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions), localProxyPort: nil, relayPort: nil, relayID: nil, @@ -203,11 +184,11 @@ extension SessionRemoteWorkspaceSnapshot { if let identityFile = Self.normalizedIdentityPath(identityFile) { arguments += ["-i", identityFile] } - let normalizedOptions = Self.normalizedSSHOptions(sshOptions) + let normalizedOptions = WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions) for option in normalizedOptions { arguments += ["-o", option] } - if !Self.hasSSHOptionKey(normalizedOptions, key: "RequestTTY") { + if !SSHCommandArgumentSupport.hasOptionKey(normalizedOptions, key: "RequestTTY") { arguments.append("-tt") } arguments.append(normalizedDestination) @@ -218,14 +199,6 @@ extension SessionRemoteWorkspaceSnapshot { WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(value) } - private static func normalizedSSHOptions(_ options: [String]) -> [String] { - WorkspaceRemoteSSHOptionFilter.durableOptions(options) - } - - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - WorkspaceRemoteSSHOptionFilter.hasOptionKey(options, key: key) - } - private static func shellQuote(_ value: String) -> String { let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" if value.range(of: safePattern, options: .regularExpression) != nil { diff --git a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift index bc06e2b665e8..0a112725fc5f 100644 --- a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift +++ b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift @@ -1,17 +1,12 @@ import Foundation enum WorkspaceRemoteSSHBatchCommandBuilder { - private static let batchSSHControlOptionKeys: Set = [ - "controlmaster", - "controlpersist", - ] - static func daemonTransportArguments( configuration: WorkspaceRemoteConfiguration, remotePath: String ) -> [String] { - let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(shellSingleQuoted(script))" + let script = "exec \(ShellArgumentQuoting.singleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(ShellArgumentQuoting.singleQuoted(script))" return ["-T"] + batchArguments(configuration: configuration) + ["-o", "RequestTTY=no", configuration.destination, command] @@ -37,7 +32,7 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { controlCommand: String, forwardSpec: String ) -> [String]? { - guard let controlPath = sshOptionValue(named: "ControlPath", in: configuration.sshOptions)? + guard let controlPath = SSHCommandArgumentSupport.optionValue(named: "ControlPath", in: configuration.sshOptions)? .trimmingCharacters(in: .whitespacesAndNewlines), !controlPath.isEmpty, controlPath.lowercased() != "none" else { @@ -50,13 +45,13 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { } private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { - let effectiveSSHOptions = backgroundSSHOptions(configuration.sshOptions) + let effectiveSSHOptions = SSHCommandArgumentSupport.backgroundOptions(configuration.sshOptions) var args: [String] = [ "-o", "ConnectTimeout=6", "-o", "ServerAliveInterval=20", "-o", "ServerAliveCountMax=2", ] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + if !SSHCommandArgumentSupport.hasOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { args += ["-o", "StrictHostKeyChecking=accept-new"] } args += ["-o", "BatchMode=yes"] @@ -74,62 +69,4 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { } return args } - - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - if sshOptionKey(option) == loweredKey { - return true - } - } - return false - } - - private static func normalizedSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private static func backgroundSSHOptions(_ options: [String]) -> [String] { - normalizedSSHOptions(options).filter { option in - guard let key = sshOptionKey(option) else { return false } - return !batchSSHControlOptionKeys.contains(key) - } - } - - private static func sshOptionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in normalizedSSHOptions(options) { - let parts = option.split( - maxSplits: 1, - omittingEmptySubsequences: true, - whereSeparator: { $0 == "=" || $0.isWhitespace } - ) - guard parts.count == 2, parts[0].lowercased() == loweredKey else { - continue - } - let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return value - } - } - return nil - } - - private static func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6844f3ac82e3..81348976739a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1169,6 +1169,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.feedButtonStyleDebug", "cmux.feedPreview", "cmux.feedTextEditorDebug", + "cmux.fileExplorerDownloadDirectory", "cmux.fileExplorerStyleDebug", "cmux.folderDragIcon", "cmux.pdfPreviewChromeDebug", diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 43fd8752410c..d41b3fbdc01b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -172,6 +172,9 @@ 5C3E0454B6C24B02A2F091A8 /* ShortcutRoutingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */; }; 76027A12C93B4538BF22C71E /* ShortcutBareStartRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */; }; E5C0F1A0E5C0F1A0E5C0F1A0 /* TerminalFindEscapeRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */; }; + D36370010000000000000001 /* ShellArgumentQuoting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36370010000000000000002 /* ShellArgumentQuoting.swift */; }; + D36370010000000000000003 /* LocalDirectoryPathNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36370010000000000000004 /* LocalDirectoryPathNormalization.swift */; }; + D36370020000000000000001 /* SSHCommandArgumentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36370020000000000000002 /* SSHCommandArgumentSupport.swift */; }; 1A8BEE693C9E4C3190CB7F20 /* MenuBarExtraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */; }; 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C07000000000000000001 /* CmuxMainWindow.swift */; }; 2F0C06000000000000000002 /* MainWindowVisibilityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C06000000000000000001 /* MainWindowVisibilityController.swift */; }; @@ -461,6 +464,7 @@ FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; }; + FE001105 /* ProcessSSHFileExplorerTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001005 /* ProcessSSHFileExplorerTransport.swift */; }; B37A00000000000000000009 /* FileExplorerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000A /* FileExplorerState.swift */; }; FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; FE001103 /* FileExplorerSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001003 /* FileExplorerSearchController.swift */; }; @@ -691,6 +695,9 @@ B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ShortcutRoutingSupport.swift; sourceTree = ""; }; AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ShortcutBareStartRouting.swift; sourceTree = ""; }; E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalFindEscapeRouting.swift; sourceTree = ""; }; + D36370010000000000000002 /* ShellArgumentQuoting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellArgumentQuoting.swift; sourceTree = ""; }; + D36370010000000000000004 /* LocalDirectoryPathNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalDirectoryPathNormalization.swift; sourceTree = ""; }; + D36370020000000000000002 /* SSHCommandArgumentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHCommandArgumentSupport.swift; sourceTree = ""; }; C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MenuBarExtraController.swift; sourceTree = ""; }; 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; 2F0C06000000000000000001 /* MainWindowVisibilityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MainWindowVisibilityController.swift; sourceTree = ""; }; @@ -1002,6 +1009,7 @@ FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = ""; }; + FE001005 /* ProcessSSHFileExplorerTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessSSHFileExplorerTransport.swift; sourceTree = ""; }; B37A0000000000000000000A /* FileExplorerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerState.swift; sourceTree = ""; }; FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = ""; }; FE001003 /* FileExplorerSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchController.swift; sourceTree = ""; }; @@ -1258,6 +1266,9 @@ A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, A5F10012A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift */, C0DEF0B10000000000000002 /* JSONCParser.swift */, + D36370010000000000000002 /* ShellArgumentQuoting.swift */, + D36370010000000000000004 /* LocalDirectoryPathNormalization.swift */, + D36370020000000000000002 /* SSHCommandArgumentSupport.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */, @@ -1428,6 +1439,7 @@ A5001653 /* CmuxConfigExecutor.swift */, A5001655 /* CmuxActionTrust.swift */, FE001001 /* FileExplorerStore.swift */, + FE001005 /* ProcessSSHFileExplorerTransport.swift */, B37A0000000000000000000A /* FileExplorerState.swift */, FE001003 /* FileExplorerSearchController.swift */, FE001004 /* FileExplorerTerminalPathInsertion.swift */, @@ -1988,6 +2000,9 @@ A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, A5F10013A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift in Sources */, C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */, + D36370010000000000000001 /* ShellArgumentQuoting.swift in Sources */, + D36370010000000000000003 /* LocalDirectoryPathNormalization.swift in Sources */, + D36370020000000000000001 /* SSHCommandArgumentSupport.swift in Sources */, D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, @@ -2162,6 +2177,7 @@ A5001652 /* CmuxConfigExecutor.swift in Sources */, A5001654 /* CmuxActionTrust.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, + FE001105 /* ProcessSSHFileExplorerTransport.swift in Sources */, B37A00000000000000000009 /* FileExplorerState.swift in Sources */, FE001103 /* FileExplorerSearchController.swift in Sources */, FE001104 /* FileExplorerTerminalPathInsertion.swift in Sources */, diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index e184db2dfedd..898801ef575f 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -42,10 +42,19 @@ private final class MockFileExplorerProvider: FileExplorerProvider { } private final class MockSSHFileExplorerTransport: SSHFileExplorerTransport { + struct DownloadRequest: Equatable { + let remotePath: String + let isDirectory: Bool + let connection: SSHFileExplorerConnection + let localDirectory: String + } + var homePath: Result var listings: [String: Result<[FileExplorerEntry], Error>] = [:] + var downloadResult: Result? private(set) var resolvedHomeConnections: [SSHFileExplorerConnection] = [] private(set) var listedPaths: [String] = [] + private(set) var downloadRequests: [DownloadRequest] = [] init(homePath: Result = .success("/home/dev")) { self.homePath = homePath @@ -67,6 +76,24 @@ private final class MockSSHFileExplorerTransport: SSHFileExplorerTransport { } return [] } + + func download( + remotePath: String, + isDirectory: Bool, + connection: SSHFileExplorerConnection, + toLocalDirectory localDirectory: String + ) async throws -> String { + downloadRequests.append(DownloadRequest( + remotePath: remotePath, + isDirectory: isDirectory, + connection: connection, + localDirectory: localDirectory + )) + if let downloadResult { + return try downloadResult.get() + } + return (localDirectory as NSString).appendingPathComponent((remotePath as NSString).lastPathComponent) + } } private final class DeferredListFileExplorerProvider: FileExplorerProvider { @@ -205,6 +232,177 @@ final class FileExplorerStoreTests: XCTestCase { XCTAssertEqual(transport.listedPaths, ["/home/dev"]) } + func testRemoteDownloadUsesSSHProviderConnection() async throws { + let transport = MockSSHFileExplorerTransport() + let connection = SSHFileExplorerConnection( + destination: "dev@ubuntu-host", + port: 2222, + identityFile: "/Users/alice/.ssh/id_ed25519", + sshOptions: ["ControlPath /tmp/cmux-ssh-%C"] + ) + let provider = SSHFileExplorerProvider( + connection: connection, + displayTarget: "dev@ubuntu-host:2222", + homePath: "/home/dev", + isAvailable: true, + transport: transport + ) + + let localPath = try await provider.download( + remotePath: "/home/dev/report.txt", + isDirectory: false, + toLocalDirectory: "/Users/alice/Downloads" + ) + + XCTAssertEqual(localPath, "/Users/alice/Downloads/report.txt") + XCTAssertEqual(transport.downloadRequests, [ + MockSSHFileExplorerTransport.DownloadRequest( + remotePath: "/home/dev/report.txt", + isDirectory: false, + connection: connection, + localDirectory: "/Users/alice/Downloads" + ) + ]) + } + + func testRemoteDownloadRejectsUnavailableProvider() async { + let provider = SSHFileExplorerProvider( + destination: "dev@ubuntu-host", + port: nil, + identityFile: nil, + sshOptions: [], + homePath: "/home/dev", + isAvailable: false, + transport: MockSSHFileExplorerTransport() + ) + + do { + _ = try await provider.download( + remotePath: "/home/dev/report.txt", + isDirectory: false, + toLocalDirectory: "/Users/alice/Downloads" + ) + XCTFail("Expected unavailable provider to reject remote download") + } catch FileExplorerError.providerUnavailable { + } catch { + XCTFail("Expected providerUnavailable, got \(error)") + } + } + + func testRemoteDownloadTargetRejectsExistingLocalItem() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-file-explorer-download-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + let existingFile = directory.appendingPathComponent("report.txt") + try Data("existing".utf8).write(to: existingFile) + + do { + _ = try ProcessSSHFileExplorerTransport.downloadTargetPathForTesting( + remotePath: "/home/dev/report.txt", + localDirectory: directory.path + ) + XCTFail("Expected existing local target to reject remote download") + } catch FileExplorerError.downloadFailed(let detail) { + XCTAssertTrue(detail.contains(existingFile.path)) + } catch { + XCTFail("Expected downloadFailed, got \(error)") + } + } + + func testRemoteDownloadSCPArgumentsShellQuoteRemotePathAndBracketIPv6() { + let args = ProcessSSHFileExplorerTransport.scpDownloadArgumentsForTesting( + remotePath: "/home/dev/Quarterly Report's.pdf", + isDirectory: true, + connection: SSHFileExplorerConnection( + destination: "dev@2001:db8::1", + port: 2222, + identityFile: "/Users/alice/.ssh/id_ed25519", + sshOptions: ["UserKnownHostsFile=/dev/null"] + ), + localDestinationPath: "/Users/alice/Downloads/Quarterly Report's.pdf" + ) + + XCTAssertTrue(args.contains("-r")) + XCTAssertTrue(args.contains("-O")) + XCTAssertTrue(args.contains("-P")) + XCTAssertTrue(args.contains("2222")) + XCTAssertTrue(args.contains("-i")) + XCTAssertTrue(args.contains("/Users/alice/.ssh/id_ed25519")) + XCTAssertEqual( + Array(args.suffix(2)), + [ + "dev@[2001:db8::1]:'/home/dev/Quarterly Report'\\''s.pdf'", + "/Users/alice/Downloads/Quarterly Report's.pdf", + ] + ) + } + + func testRemoteDownloadSCPArgumentsUseBackgroundSSHOptions() { + let args = ProcessSSHFileExplorerTransport.scpDownloadArgumentsForTesting( + remotePath: "/home/dev/report.txt", + isDirectory: false, + connection: SSHFileExplorerConnection( + destination: "dev@ubuntu-host", + port: nil, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=10m", + "ControlPath=/tmp/cmux-ssh-%C", + "StrictHostKeyChecking=no", + ] + ), + localDestinationPath: "/Users/alice/Downloads/report.txt" + ) + + XCTAssertFalse(args.contains("ControlMaster=auto")) + XCTAssertFalse(args.contains("ControlPersist=10m")) + XCTAssertTrue(args.contains("ControlMaster=no")) + XCTAssertTrue(args.contains("ControlPath=/tmp/cmux-ssh-%C")) + XCTAssertTrue(args.contains("StrictHostKeyChecking=no")) + XCTAssertFalse(args.contains("StrictHostKeyChecking=accept-new")) + } + + func testRemoteDownloadLaunchFailureUsesSanitizedError() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-file-explorer-download-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + let missingExecutable = directory.appendingPathComponent("missing-scp").path + ProcessSSHFileExplorerTransport.scpExecutablePathOverrideForTesting = missingExecutable + defer { + ProcessSSHFileExplorerTransport.scpExecutablePathOverrideForTesting = nil + } + + do { + _ = try await ProcessSSHFileExplorerTransport.shared.download( + remotePath: "/home/dev/report.txt", + isDirectory: false, + connection: SSHFileExplorerConnection( + destination: "dev@ubuntu-host", + port: nil, + identityFile: nil, + sshOptions: [] + ), + toLocalDirectory: directory.path + ) + XCTFail("Expected missing scp executable to fail") + } catch FileExplorerError.downloadFailed(let detail) { + XCTAssertEqual(detail, "Unable to start the download helper.") + XCTAssertFalse(detail.contains(missingExecutable)) + XCTAssertFalse(detail.contains("/usr/bin/scp")) + } catch { + XCTFail("Expected sanitized downloadFailed, got \(error)") + } + } + func testSwitchingFromLocalToRemoteRepointsTreeToRemoteHome() async throws { let transport = MockSSHFileExplorerTransport(homePath: .success("/home/dev")) transport.listings["/home/dev"] = .success([ @@ -694,6 +892,7 @@ final class FileSearchControllerTests: XCTestCase { let coordinator = FileExplorerPanelView.Coordinator( store: store, state: state, + workspaceId: nil, onOpenFilePreview: { _ in } ) let container = FileExplorerContainerView( @@ -731,6 +930,7 @@ final class FileSearchControllerTests: XCTestCase { let coordinator = FileExplorerPanelView.Coordinator( store: store, state: state, + workspaceId: nil, onOpenFilePreview: { _ in } ) let container = FileExplorerContainerView( diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 2bec1d8464d2..a2963782e59e 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -1428,6 +1428,24 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + func testDetectsForegroundSSHSessionWithWhitespaceControlPathContainingEquals() { + let session = TerminalSSHSessionDetector.detectForTesting( + ttyName: "/dev/ttys004", + processes: [ + .init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"), + ], + argumentsByPID: [ + 2145: [ + "ssh", + "-o", "ControlPath /tmp/cmux-ssh=with-equals-%C", + "lawrence@example.com", + ], + ] + ) + + XCTAssertEqual(session?.controlPath, "/tmp/cmux-ssh=with-equals-%C") + } + func testDetectsForegroundSSHSessionWithShortControlPathFlag() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004",