diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings index d9008bd0f3a4..046dff95cb27 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings @@ -25283,6 +25283,1316 @@ } } } + }, + "updater.installFailed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Didn't Install" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لم يُثبَّت التحديث" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Ažuriranje nije instalirano" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Opdateringen blev ikke installeret" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Update wurde nicht installiert" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "La actualización no se instaló" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "La mise à jour ne s'est pas installée" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "L'aggiornamento non è stato installato" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "アップデートをインストールできませんでした" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ការធ្វើបច្ចុប្បន្នភាពមិនបានដំឡើងទេ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "업데이트가 설치되지 않음" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Oppdateringen ble ikke installert" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Aktualizacja nie została zainstalowana" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "A atualização não foi instalada" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Обновление не установилось" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ไม่ได้ติดตั้งอัปเดต" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Güncelleme yüklenmedi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Оновлення не встановилося" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Bản cập nhật chưa được cài đặt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "更新未安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "更新未安裝" + } + } + } + }, + "updater.card.tryAgain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try Again" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "حاول مرة أخرى" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Pokušaj ponovo" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Prøv igen" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Erneut versuchen" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Reintentar" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Réessayer" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Riprova" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "再試行" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ព្យាយាមម្ដងទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "다시 시도" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Prøv igjen" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Spróbuj ponownie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Tentar novamente" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Повторить" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Tekrar Dene" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Спробувати ще раз" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Thử lại" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "重试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "再試一次" + } + } + } + }, + "updater.installFailed.translocated.short": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux runs from a read-only copy" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "يعمل cmux من نسخة للقراءة فقط" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "cmux radi iz kopije samo za čitanje" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "cmux kører fra en skrivebeskyttet kopi" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "cmux läuft aus einer schreibgeschützten Kopie" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "cmux se ejecuta desde una copia de solo lectura" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "cmux s'exécute depuis une copie en lecture seule" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "cmux è eseguito da una copia di sola lettura" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "cmux は読み取り専用のコピーから実行されています" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "cmux ដំណើរការពីច្បាប់ចម្លងសម្រាប់តែអាន" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "cmux가 읽기 전용 사본에서 실행 중" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "cmux kjører fra en skrivebeskyttet kopi" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "cmux działa z kopii tylko do odczytu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "O cmux está sendo executado de uma cópia somente leitura" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "cmux запущен из копии только для чтения" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "cmux ทำงานจากสำเนาแบบอ่านอย่างเดียว" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "cmux salt okunur bir kopyadan çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "cmux запущено з копії лише для читання" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "cmux đang chạy từ bản sao chỉ đọc" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "cmux 正从只读副本运行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "cmux 正從唯讀副本執行" + } + } + } + }, + "updater.installFailed.translocated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS runs this cmux from a read-only copy, so the installer couldn't replace it. Move cmux to Applications and open it from there, or download the update manually." + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "يشغّل macOS نسخة cmux هذه من نسخة للقراءة فقط، لذا لم يتمكن المثبّت من استبدالها. انقل cmux إلى التطبيقات وافتحه من هناك، أو نزّل التحديث يدويًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "macOS pokreće ovaj cmux iz kopije samo za čitanje, pa ga instalater nije mogao zamijeniti. Premjestite cmux u Aplikacije i otvorite ga odatle ili ručno preuzmite ažuriranje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "macOS kører denne cmux fra en skrivebeskyttet kopi, så installationsprogrammet kunne ikke erstatte den. Flyt cmux til Programmer, og åbn den derfra, eller hent opdateringen manuelt." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "macOS führt dieses cmux aus einer schreibgeschützten Kopie aus, daher konnte das Installationsprogramm es nicht ersetzen. Bewege cmux in den Ordner „Programme“ und öffne es dort, oder lade das Update manuell herunter." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "macOS ejecuta este cmux desde una copia de solo lectura, así que el instalador no pudo reemplazarlo. Mueve cmux a Aplicaciones y ábrelo desde ahí, o descarga la actualización manualmente." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "macOS exécute ce cmux depuis une copie en lecture seule, l'installateur n'a donc pas pu le remplacer. Déplacez cmux dans Applications et ouvrez-le depuis là, ou téléchargez la mise à jour manuellement." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "macOS esegue questo cmux da una copia di sola lettura, quindi il programma di installazione non ha potuto sostituirlo. Sposta cmux in Applicazioni e aprilo da lì, oppure scarica l'aggiornamento manualmente." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "macOS はこの cmux を読み取り専用のコピーから実行しているため、インストーラが置き換えられませんでした。cmux をアプリケーションに移動してそこから開くか、アップデートを手動でダウンロードしてください。" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "macOS ដំណើរការ cmux នេះពីច្បាប់ចម្លងសម្រាប់តែអាន ដូច្នេះកម្មវិធីដំឡើងមិនអាចជំនួសវាបានទេ។ ផ្លាស់ទី cmux ទៅ Applications ហើយបើកវាពីទីនោះ ឬទាញយកការធ្វើបច្ចុប្បន្នភាពដោយដៃ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "macOS가 이 cmux를 읽기 전용 사본에서 실행하고 있어 설치 프로그램이 교체하지 못했습니다. cmux를 응용 프로그램으로 옮긴 후 그곳에서 열거나, 업데이트를 직접 다운로드하세요." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "macOS kjører denne cmux fra en skrivebeskyttet kopi, så installasjonsprogrammet kunne ikke erstatte den. Flytt cmux til Programmer og åpne den derfra, eller last ned oppdateringen manuelt." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "macOS uruchamia tego cmux z kopii tylko do odczytu, więc instalator nie mógł go zastąpić. Przenieś cmux do folderu Aplikacje i otwórz go stamtąd lub pobierz aktualizację ręcznie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "O macOS executa este cmux a partir de uma cópia somente leitura, então o instalador não conseguiu substituí-lo. Mova o cmux para Aplicativos e abra-o de lá, ou baixe a atualização manualmente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "macOS запускает этот cmux из копии только для чтения, поэтому установщик не смог его заменить. Переместите cmux в «Программы» и откройте его оттуда или скачайте обновление вручную." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "macOS เรียกใช้ cmux นี้จากสำเนาแบบอ่านอย่างเดียว ตัวติดตั้งจึงแทนที่ไม่ได้ ย้าย cmux ไปที่ Applications แล้วเปิดจากที่นั่น หรือดาวน์โหลดอัปเดตด้วยตนเอง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "macOS bu cmux'u salt okunur bir kopyadan çalıştırıyor, bu yüzden yükleyici onu değiştiremedi. cmux'u Uygulamalar'a taşıyıp oradan açın veya güncellemeyi elle indirin." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "macOS запускає цей cmux з копії лише для читання, тому інсталятор не зміг його замінити. Перемістіть cmux у «Програми» й відкрийте його звідти або завантажте оновлення вручну." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "macOS chạy cmux này từ một bản sao chỉ đọc nên trình cài đặt không thể thay thế nó. Hãy chuyển cmux vào Applications rồi mở từ đó, hoặc tải bản cập nhật thủ công." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "macOS 正从只读副本运行此 cmux,因此安装器无法替换它。请将 cmux 移到“应用程序”并从那里打开,或手动下载更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "macOS 正從唯讀副本執行此 cmux,因此安裝程式無法取代它。請將 cmux 移到「應用程式」並從那裡開啟,或手動下載更新。" + } + } + } + }, + "updater.installFailed.folder.short": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux can't replace itself here" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لا يستطيع cmux استبدال نفسه هنا" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "cmux se ovdje ne može zamijeniti" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "cmux kan ikke erstatte sig selv her" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "cmux kann sich hier nicht ersetzen" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "cmux no puede reemplazarse aquí" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "cmux ne peut pas se remplacer ici" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "cmux non può sostituirsi qui" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "cmux はこの場所で自身を置き換えられません" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "cmux មិនអាចជំនួសខ្លួនឯងនៅទីនេះបានទេ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "cmux가 이 위치에서 자신을 교체할 수 없음" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "cmux kan ikke erstatte seg selv her" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "cmux nie może się tu zastąpić" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "O cmux não pode se substituir aqui" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "cmux не может заменить себя здесь" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "cmux แทนที่ตัวเองที่นี่ไม่ได้" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "cmux burada kendini değiştiremiyor" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "cmux не може замінити себе тут" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "cmux không thể tự thay thế tại đây" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "cmux 无法在此处替换自身" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "cmux 無法在此處取代自身" + } + } + } + }, + "updater.installFailed.folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The installer couldn't replace cmux in %@. Move cmux to Applications, or download the update manually." + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تعذّر على المثبّت استبدال cmux في %@. انقل cmux إلى التطبيقات، أو نزّل التحديث يدويًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Instalater nije mogao zamijeniti cmux u %@. Premjestite cmux u Aplikacije ili ručno preuzmite ažuriranje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Installationsprogrammet kunne ikke erstatte cmux i %@. Flyt cmux til Programmer, eller hent opdateringen manuelt." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Das Installationsprogramm konnte cmux in %@ nicht ersetzen. Bewege cmux in den Ordner „Programme“ oder lade das Update manuell herunter." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "El instalador no pudo reemplazar cmux en %@. Mueve cmux a Aplicaciones o descarga la actualización manualmente." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "L'installateur n'a pas pu remplacer cmux dans %@. Déplacez cmux dans Applications ou téléchargez la mise à jour manuellement." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Il programma di installazione non ha potuto sostituire cmux in %@. Sposta cmux in Applicazioni oppure scarica l'aggiornamento manualmente." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "インストーラは %@ にある cmux を置き換えられませんでした。cmux をアプリケーションに移動するか、アップデートを手動でダウンロードしてください。" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "កម្មវិធីដំឡើងមិនអាចជំនួស cmux នៅក្នុង %@ បានទេ។ ផ្លាស់ទី cmux ទៅ Applications ឬទាញយកការធ្វើបច្ចុប្បន្នភាពដោយដៃ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설치 프로그램이 %@에 있는 cmux를 교체하지 못했습니다. cmux를 응용 프로그램으로 옮기거나 업데이트를 직접 다운로드하세요." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Installasjonsprogrammet kunne ikke erstatte cmux i %@. Flytt cmux til Programmer, eller last ned oppdateringen manuelt." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Instalator nie mógł zastąpić cmux w %@. Przenieś cmux do folderu Aplikacje lub pobierz aktualizację ręcznie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "O instalador não conseguiu substituir o cmux em %@. Mova o cmux para Aplicativos ou baixe a atualização manualmente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Установщик не смог заменить cmux в %@. Переместите cmux в «Программы» или скачайте обновление вручную." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ตัวติดตั้งแทนที่ cmux ใน %@ ไม่ได้ ย้าย cmux ไปที่ Applications หรือดาวน์โหลดอัปเดตด้วยตนเอง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yükleyici %@ içindeki cmux'u değiştiremedi. cmux'u Uygulamalar'a taşıyın veya güncellemeyi elle indirin." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Інсталятор не зміг замінити cmux у %@. Перемістіть cmux у «Програми» або завантажте оновлення вручну." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Trình cài đặt không thể thay thế cmux trong %@. Hãy chuyển cmux vào Applications hoặc tải bản cập nhật thủ công." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "安装器无法替换 %@ 中的 cmux。请将 cmux 移到“应用程序”,或手动下载更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "安裝程式無法取代 %@ 中的 cmux。請將 cmux 移到「應用程式」,或手動下載更新。" + } + } + } + }, + "updater.installFailed.space.short": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not enough free disk space" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لا توجد مساحة كافية على القرص" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Nema dovoljno slobodnog prostora na disku" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Ikke nok ledig diskplads" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Nicht genug freier Speicherplatz" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No hay suficiente espacio libre en disco" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Espace disque insuffisant" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Spazio su disco insufficiente" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ディスクの空き容量が足りません" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ទំហំថាសទំនេរមិនគ្រប់គ្រាន់" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "디스크 여유 공간 부족" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Ikke nok ledig diskplass" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Za mało wolnego miejsca na dysku" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Espaço livre em disco insuficiente" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Недостаточно свободного места на диске" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "พื้นที่ดิสก์ว่างไม่พอ" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeterli boş disk alanı yok" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Недостатньо вільного місця на диску" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Không đủ dung lượng trống" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "磁盘可用空间不足" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "磁碟可用空間不足" + } + } + } + }, + "updater.installFailed.space": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "There wasn't enough free disk space to install the update. Free some space and try again, or download the update manually." + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لم تكن هناك مساحة كافية على القرص لتثبيت التحديث. حرّر بعض المساحة وحاول مرة أخرى، أو نزّل التحديث يدويًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Nije bilo dovoljno slobodnog prostora na disku za instalaciju ažuriranja. Oslobodite prostor i pokušajte ponovo ili ručno preuzmite ažuriranje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Der var ikke nok ledig diskplads til at installere opdateringen. Frigør plads, og prøv igen, eller hent opdateringen manuelt." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Es war nicht genug freier Speicherplatz für das Update vorhanden. Gib Speicher frei und versuche es erneut, oder lade das Update manuell herunter." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No había suficiente espacio libre en disco para instalar la actualización. Libera espacio y vuelve a intentarlo, o descarga la actualización manualmente." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Il n'y avait pas assez d'espace disque pour installer la mise à jour. Libérez de l'espace et réessayez, ou téléchargez la mise à jour manuellement." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Lo spazio libero su disco non bastava per installare l'aggiornamento. Libera spazio e riprova, oppure scarica l'aggiornamento manualmente." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "アップデートをインストールするためのディスクの空き容量が足りませんでした。空き容量を増やして再試行するか、アップデートを手動でダウンロードしてください。" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "មិនមានទំហំថាសទំនេរគ្រប់គ្រាន់ដើម្បីដំឡើងការធ្វើបច្ចុប្បន្នភាពទេ។ បង្កើនទំហំទំនេរ ហើយព្យាយាមម្ដងទៀត ឬទាញយកការធ្វើបច្ចុប្បន្នភាពដោយដៃ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "업데이트를 설치할 디스크 여유 공간이 부족했습니다. 공간을 확보한 후 다시 시도하거나 업데이트를 직접 다운로드하세요." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Det var ikke nok ledig diskplass til å installere oppdateringen. Frigjør plass og prøv igjen, eller last ned oppdateringen manuelt." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Brakowało wolnego miejsca na dysku, aby zainstalować aktualizację. Zwolnij miejsce i spróbuj ponownie lub pobierz aktualizację ręcznie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Não havia espaço livre em disco suficiente para instalar a atualização. Libere espaço e tente novamente, ou baixe a atualização manualmente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Для установки обновления не хватило свободного места на диске. Освободите место и повторите попытку или скачайте обновление вручную." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "พื้นที่ดิสก์ว่างไม่พอสำหรับติดตั้งอัปเดต เพิ่มพื้นที่ว่างแล้วลองอีกครั้ง หรือดาวน์โหลดอัปเดตด้วยตนเอง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Güncellemeyi yüklemek için yeterli boş disk alanı yoktu. Biraz yer açıp tekrar deneyin veya güncellemeyi elle indirin." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Для встановлення оновлення забракло вільного місця на диску. Звільніть місце й повторіть спробу або завантажте оновлення вручну." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Không đủ dung lượng trống để cài đặt bản cập nhật. Hãy giải phóng dung lượng rồi thử lại, hoặc tải bản cập nhật thủ công." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "磁盘可用空间不足,无法安装更新。请释放一些空间后重试,或手动下载更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "磁碟可用空間不足,無法安裝更新。請釋放一些空間後再試一次,或手動下載更新。" + } + } + } + }, + "updater.installFailed.unknown.short": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The installer kept this version" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "أبقى المثبّت على هذا الإصدار" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Instalater je zadržao ovu verziju" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Installationsprogrammet beholdt denne version" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Das Installationsprogramm hat diese Version behalten" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "El instalador mantuvo esta versión" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "L'installateur a conservé cette version" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Il programma di installazione ha mantenuto questa versione" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "インストーラはこのバージョンのままにしました" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "កម្មវិធីដំឡើងបានរក្សាកំណែនេះ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설치 프로그램이 이 버전을 유지함" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Installasjonsprogrammet beholdt denne versjonen" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Instalator zachował tę wersję" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "O instalador manteve esta versão" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Установщик оставил эту версию" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ตัวติดตั้งคงเวอร์ชันนี้ไว้" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yükleyici bu sürümü korudu" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Інсталятор залишив цю версію" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Trình cài đặt giữ nguyên phiên bản này" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "安装器保留了此版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "安裝程式保留了此版本" + } + } + } + }, + "updater.installFailed.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The installer kept this version of cmux and gave no reason. Try again, or download the update manually." + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "أبقى المثبّت على هذا الإصدار من cmux دون ذكر السبب. حاول مرة أخرى، أو نزّل التحديث يدويًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Instalater je zadržao ovu verziju cmux-a bez navođenja razloga. Pokušajte ponovo ili ručno preuzmite ažuriranje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Installationsprogrammet beholdt denne version af cmux uden at angive en årsag. Prøv igen, eller hent opdateringen manuelt." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Das Installationsprogramm hat diese cmux-Version ohne Angabe eines Grundes behalten. Versuche es erneut oder lade das Update manuell herunter." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "El instalador mantuvo esta versión de cmux sin dar ningún motivo. Vuelve a intentarlo o descarga la actualización manualmente." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "L'installateur a conservé cette version de cmux sans donner de raison. Réessayez ou téléchargez la mise à jour manuellement." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Il programma di installazione ha mantenuto questa versione di cmux senza indicare il motivo. Riprova oppure scarica l'aggiornamento manualmente." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "インストーラは理由を示さずにこのバージョンの cmux のままにしました。再試行するか、アップデートを手動でダウンロードしてください。" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "កម្មវិធីដំឡើងបានរក្សាកំណែ cmux នេះដោយមិនផ្ដល់មូលហេតុ។ ព្យាយាមម្ដងទៀត ឬទាញយកការធ្វើបច្ចុប្បន្នភាពដោយដៃ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설치 프로그램이 이유 없이 이 버전의 cmux를 유지했습니다. 다시 시도하거나 업데이트를 직접 다운로드하세요." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Installasjonsprogrammet beholdt denne versjonen av cmux uten å oppgi noen grunn. Prøv igjen, eller last ned oppdateringen manuelt." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Instalator zachował tę wersję cmux bez podania przyczyny. Spróbuj ponownie lub pobierz aktualizację ręcznie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "O instalador manteve esta versão do cmux sem informar o motivo. Tente novamente ou baixe a atualização manualmente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Установщик оставил эту версию cmux, не указав причину. Повторите попытку или скачайте обновление вручную." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ตัวติดตั้งคง cmux เวอร์ชันนี้ไว้โดยไม่ระบุเหตุผล ลองอีกครั้ง หรือดาวน์โหลดอัปเดตด้วยตนเอง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yükleyici cmux'un bu sürümünü bir neden belirtmeden korudu. Tekrar deneyin veya güncellemeyi elle indirin." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Інсталятор залишив цю версію cmux, не вказавши причини. Повторіть спробу або завантажте оновлення вручну." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Trình cài đặt giữ nguyên phiên bản cmux này mà không nêu lý do. Hãy thử lại hoặc tải bản cập nhật thủ công." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "安装器保留了此版本的 cmux,且未给出原因。请重试,或手动下载更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "安裝程式保留了此版本的 cmux,且未提供原因。請再試一次,或手動下載更新。" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateInstallCheck.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateInstallCheck.swift new file mode 100644 index 000000000000..2b7fbbd22768 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateInstallCheck.swift @@ -0,0 +1,137 @@ +public import Foundation + +/// Restart to Update hands the app to Sparkle's installer, which swaps the bundle after the app has +/// quit. When the swap fails there is no app on screen to say so: Sparkle reopens the old build, +/// the update stages again and the card offers Restart to Update as if nothing happened (Leo +/// 2026-10-21: "If an installed update ever fails, the app must say why instead of offering +/// Restart again."). +/// +/// Just before Sparkle relaunches, the updater records here which build it installs over which. +/// The next launch reads the record once (``consume(defaults:build:now:)``): still on the old +/// build, the install failed, and the staged update card says why (``UpdateInstallFailure``). +public nonisolated struct UpdateInstallCheck: Codable, Equatable, Sendable { + /// The running build when the install began (`CFBundleVersion`). + public var fromBuild: String + /// The build Sparkle installs. + public var targetBuild: String + public var recordedAt: Date + + public init(fromBuild: String, targetBuild: String, recordedAt: Date) { + self.fromBuild = fromBuild + self.targetBuild = targetBuild + self.recordedAt = recordedAt + } + + /// The defaults key of the record (the app's own domain, so each build channel keeps its own). + public static let key = "cmux.next.updates.installCheck" + /// A record older than this judges nothing: the launch it was written for is long past. + public static let maxAge: TimeInterval = 24 * 60 * 60 + + /// What this launch says about the install the record was written for. + public enum Outcome: Equatable, Sendable { + /// The relaunch runs the build Sparkle installed. + case installed + /// The relaunch runs the build the install was meant to replace. + case failed + } + + /// The outcome for a launch of `build`, or nil when the record says nothing about it: stale, + /// written in the future, a no-op install, or a third build (one installed by hand). + public func outcome(runningBuild build: String, now: Date) -> Outcome? { + let age = now.timeIntervalSince(recordedAt) + guard age >= -60, age <= Self.maxAge, fromBuild != targetBuild else { return nil } + if build == targetBuild { return .installed } + if build == fromBuild { return .failed } + return nil + } + + /// Writes `record`, or removes any record (nil: Sparkle did not name the build it installs). + public static func write(_ record: UpdateInstallCheck?, defaults: UserDefaults) { + guard let record, let data = try? JSONEncoder().encode(record) else { + defaults.removeObject(forKey: key) + return + } + defaults.set(data, forKey: key) + } + + /// At launch: reads the record once and, when this launch is still the build it was meant to + /// replace, returns the build whose install failed and why (logged either way). + public static func launchFailure(defaults: UserDefaults, build: String, now: Date, bundle: URL = Bundle.main.bundleURL, + log: UpdateLogBuffer) -> (build: String, failure: UpdateInstallFailure)? { + guard let last = consume(defaults: defaults, build: build, now: now) else { return nil } + guard last.outcome == .failed else { + log.append("installed \(last.record.targetBuild) over \(last.record.fromBuild)") + return nil + } + // concurrency-allow: once per launch after a failed install: a path test, two access checks and one volume read. + let failure = UpdateInstallFailure.diagnose(bundle: bundle) + log.append("install of \(last.record.targetBuild) did not replace \(last.record.fromBuild): \(failure)") + return (last.record.targetBuild, failure) + } + + /// Reads and clears the record at launch: the record and this launch's outcome, else nil (no + /// record, unreadable, or one that says nothing about this launch). + public static func consume(defaults: UserDefaults, build: String, now: Date = Date()) + -> (record: UpdateInstallCheck, outcome: Outcome)? { + let data = defaults.data(forKey: key) + if data != nil { defaults.removeObject(forKey: key) } + guard let record = data.flatMap({ try? JSONDecoder().decode(UpdateInstallCheck.self, from: $0) }), + let outcome = record.outcome(runningBuild: build, now: now) else { return nil } + return (record, outcome) + } +} + +/// Why an install left the old build running, read from the file system at the launch after it. +/// Sparkle's installer runs after the app quit and leaves no error the app can read, so these are +/// the causes the app can see for itself; anything else says the installer gave no reason. +public nonisolated enum UpdateInstallFailure: Equatable, Sendable { + /// macOS runs the app from a randomized read-only copy (App Translocation: the app was opened + /// from Downloads or a disk image without being moved), which no installer can replace. + case translocated + /// The app's bundle or its folder is not writable (path of the folder). + case folderNotWritable(String) + /// Free bytes on the app's volume, and the bytes an install needs. + case notEnoughSpace(free: Int64, needed: Int64) + /// None of the above: the installer left the old build and no cause shows. + case notReplaced + + /// The cause for the app at `bundle`. Only the place checks of ``UpdateInstallPreflight`` + /// apply: Sparkle removes its staged copy whether the install worked or not. + public static func diagnose(bundle: URL, files: FileManager = .default) -> UpdateInstallFailure { + if bundle.path.contains("/AppTranslocation/") { return .translocated } + let preflight = UpdateInstallPreflight(bundle: bundle, updateBytes: nil, + stagedInstallation: UpdateInstallPreflight.stagedInstallation(bundleIdentifier: nil)) + switch preflight.placeFailure(files: files) { + case .folderNotWritable(let folder): return .folderNotWritable(folder) + case .notEnoughSpace(let free, let needed): return .notEnoughSpace(free: free, needed: needed) + case .updateMissing, nil: return .notReplaced + } + } + + /// The reason the staged update card shows for `staged`: only the build whose install failed + /// carries it; a newer build is a new install, offered as usual. + public static func forStaged(_ staged: String?, failed: (build: String, failure: UpdateInstallFailure)?) -> UpdateInstallFailure? { + guard let failed, failed.build == staged else { return nil } + return failed.failure + } + + /// The staged update card's one-line reason. + public var summary: String { + switch self { + case .translocated: UpdaterStrings.installFailedTranslocatedShort + case .folderNotWritable: UpdaterStrings.installFailedFolderShort + case .notEnoughSpace: UpdaterStrings.installFailedSpaceShort + case .notReplaced: UpdaterStrings.installFailedUnknownShort + } + } + + /// The full reason and what to do (the card's popover and button help). + public var message: String { + switch self { + case .translocated: UpdaterStrings.installFailedTranslocated + case .folderNotWritable(let folder): UpdaterStrings.installFailedFolder(folder) + case .notEnoughSpace: UpdaterStrings.installFailedSpace + case .notReplaced: UpdaterStrings.installFailedUnknown + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateInstallPreflight.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateInstallPreflight.swift index bfce8bd3f050..d98210a02be3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateInstallPreflight.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateInstallPreflight.swift @@ -46,6 +46,16 @@ nonisolated public struct UpdateInstallPreflight: Sendable { /// The first failed check, or nil when the install may start. public func failure(files: FileManager = .default) -> Failure? { + if let failure = placeFailure(files: files) { return failure } + if let staged = try? files.contentsOfDirectory(atPath: stagedInstallation.path) { + return staged.contains(where: { !$0.hasPrefix(".") }) ? nil : .updateMissing + } + return files.fileExists(atPath: stagedInstallation.deletingLastPathComponent().path) ? .updateMissing : nil + } + + /// The checks of where the app is: its folder is writable and its volume has room. They + /// also explain an install that failed after the app quit (``UpdateInstallFailure``). + public func placeFailure(files: FileManager = .default) -> Failure? { let folder = bundle.deletingLastPathComponent() guard files.isWritableFile(atPath: folder.path), files.isWritableFile(atPath: bundle.path) else { return .folderNotWritable(folder.path) @@ -55,10 +65,7 @@ nonisolated public struct UpdateInstallPreflight: Sendable { let free = values.volumeAvailableCapacityForImportantUsage, free < needed { return .notEnoughSpace(free: free, needed: needed) } - if let staged = try? files.contentsOfDirectory(atPath: stagedInstallation.path) { - return staged.contains(where: { !$0.hasPrefix(".") }) ? nil : .updateMissing - } - return files.fileExists(atPath: stagedInstallation.deletingLastPathComponent().path) ? .updateMissing : nil + return nil } /// Whether the staged-update check could read Sparkle's folders (false: diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateReadyCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateReadyCard.swift index 3077f7a7eca6..9ea65967d7e9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateReadyCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateReadyCard.swift @@ -23,8 +23,12 @@ nonisolated public struct UpdateReadyCard: Equatable, Sendable { public var automaticUpdates: Bool public var notes: UpdateReadyNotes + /// Why the last install of this build left the old one running, when it did + /// (``UpdateInstallCheck``): the card says so instead of offering Restart to Update again. + public var failure: UpdateInstallFailure? + public init(version: String?, isInstalling: Bool, automaticUpdates: Bool, notes: UpdateReadyNotes, - changelog: UpdateChangelog? = nil) { + changelog: UpdateChangelog? = nil, failure: UpdateInstallFailure? = nil) { // While the click installs, the card says so in its title too, not only on its disabled button. title = isInstalling ? UpdaterStrings.installingUpdate : UpdaterStrings.readyToInstall detail = (changelog ?? version.map { UpdateChangelog(version: $0, date: nil, lines: []) })?.cardDetail @@ -34,6 +38,15 @@ nonisolated public struct UpdateReadyCard: Equatable, Sendable { automaticUpdatesTitle = UpdaterStrings.automaticUpdates self.automaticUpdates = automaticUpdates self.notes = notes + // The install of this build failed: the title says it did not install, the detail line + // says why, and the popover (and the button's help) has the full reason and what to do. + self.failure = isInstalling ? nil : failure + if let failure = self.failure { + title = UpdaterStrings.installFailedTitle + detail = failure.summary + buttonTitle = UpdaterStrings.tryAgain + self.notes.headline = failure.message + } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ActionDelegate.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ActionDelegate.swift index edcebbec44ef..1930cf774801 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ActionDelegate.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ActionDelegate.swift @@ -15,6 +15,7 @@ extension UpdaterService: UpdateActionDelegate { UpdateHarness.mark("will_relaunch") #endif recordRelaunchFocus() + UpdateInstallCheck.write(installingBuild.map { .init(fromBuild: identity.build, targetBuild: $0, recordedAt: now()) }, defaults: defaults) willRelaunch?() // Only a relaunch the person asked for (a click, or a click that took an automatic install // over) takes the windows off the screen; its failure and stall paths bring them back. An diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+Flow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+Flow.swift index 2f77b1d556c3..bdd2655ce6d5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+Flow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+Flow.swift @@ -214,13 +214,13 @@ extension UpdaterService { automaticInstallInFlight = false automaticInstallFrontmost = nil log.append("automatic install ended without a relaunch (not shown)") - installAbandoned?() + abandonInstall() case .note where wasInstalling || (wasReady && clickedInstall): // The install failed: no relaunch comes. The windows come back and the failure's // sheet opens with Retry and Download Manually, so the click never ends in silence. // A staged update that ends without a click (a background failure) opens nothing. log.append("install ended without a relaunch") - installAbandoned?() + abandonInstall() presentUpdateUI?() #if DEBUG || CMUX_BENCH_HOOKS UpdateHarness.mark("failure_sheet_presented") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+IdleInstall.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+IdleInstall.swift index a27647bc4a3b..014d9b238a61 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+IdleInstall.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+IdleInstall.swift @@ -72,7 +72,7 @@ extension UpdaterService { automaticInstallInFlight = false automaticInstallFrontmost = nil // The app keeps running: any window the relaunch took off the screen comes back. - installAbandoned?() + abandonInstall() controller?.restageAfterDeclinedQuit() } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+InstallStatus.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+InstallStatus.swift index 50fffb5d5c69..2442a03bb27b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+InstallStatus.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+InstallStatus.swift @@ -41,7 +41,7 @@ extension UpdaterService { guard !installGaveUp else { return } installGaveUp = true log.append("install: no relaunch after \(status.seconds) s; the windows come back") - installAbandoned?() + abandonInstall() return } if status.seconds == Int(UpdateInstallStatus.slowAfter.components.seconds) { @@ -49,4 +49,6 @@ extension UpdaterService { } scheduleInstallTick() } + /// The install ended with this app running: its relaunch record goes (``UpdateInstallCheck``), the windows come back. + func abandonInstall() { UpdateInstallCheck.write(nil, defaults: defaults); installAbandoned?() } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ReadyCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ReadyCard.swift index 3d2742fd8a0e..f0365bd27676 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ReadyCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService+ReadyCard.swift @@ -20,7 +20,7 @@ extension UpdaterService { updateSnooze.hides(version: version, at: now()) { return nil } let notes = UpdateReadyNotes(version: stagedVersion, notes: stagedNotes, fullNotesURL: stagedReleaseNotesURL) return UpdateReadyCard(version: stagedVersion, isInstalling: !pill.isEnabled, automaticUpdates: automaticUpdates, notes: notes, - changelog: stagedChangelog) + changelog: stagedChangelog, failure: UpdateInstallFailure.forStaged(stagedBuild(), failed: failedInstall)) } /// The staged update's pill when the user asked to check while it waits. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService.swift index 224a097feba8..a2f046d49cfe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterService.swift @@ -40,8 +40,7 @@ public final class UpdaterService { public internal(set) var managedChannel: AppChannelSwitchTarget? /// `MinimumVersion` from the managed policy, or nil. public internal(set) var managedMinimumVersion: String? - /// Whether the circle shows the last probe's result (the user asked, in a - /// build without Sparkle). + /// Whether the circle shows the last probe's result (the user asked, in a build without Sparkle). public internal(set) var showsProbeResult = false /// A fixed circle phase for screenshots (`debug.update_indicator`). public var debugIndicatorPhase: UpdateIndicatorPhase? { @@ -63,8 +62,7 @@ public final class UpdaterService { /// The app-owned per-PR kill-switch reader; retaining it cancels its observation with the updater. /// ``startKillSwitchReader()`` stops the one before through this property. @ObservationIgnored var killSwitchReader: KillSwitchReader? - /// The notice whose timeout is pending, and its one-shot deadline on the - /// injected clock (no `asyncAfter`). + /// The notice whose timeout is pending, and its one-shot deadline on the injected clock (no `asyncAfter`). @ObservationIgnored var expiringCard: UpdateCard? @ObservationIgnored let cardTimer: DemandTimer /// When the install phase began (``installStatus``), nil outside it. @@ -88,8 +86,7 @@ public final class UpdaterService { @ObservationIgnored var releasePushRetry: DemandTimer /// Opens the changelog page (set by the App; the What's New page's link). @ObservationIgnored public var openChangelog: (() -> Bool)? - /// Opens the changelog page for the releases after `from` up to `to` - /// (set by the App). + /// Opens the changelog page for the releases after `from` up to `to` (set by the App). @ObservationIgnored public var openChangelogSpan: ((_ from: String, _ to: String) -> Bool)? /// Opens a web release notes page in a browser tab (set by the App; /// "See all changes" when the staged build has no signed notes). @@ -106,8 +103,7 @@ public final class UpdaterService { /// What's New after an update (WHATS-NEW-AFTER-UPDATE): the bundled /// documents and this feed's nightly digests. The App loads it at launch. public let whatsNew: WhatsNewCenter - /// Where a staged update is recorded for the next build's What's New - /// (nil: not recorded). + /// Where a staged update is recorded for the next build's What's New (nil: not recorded). @ObservationIgnored let lastUpdates: WhatsNewLastUpdateStore? /// The record last written, so an unchanged one is not written again. @ObservationIgnored var recordedUpdate: WhatsNewLastUpdate? @@ -232,6 +228,8 @@ public final class UpdaterService { @ObservationIgnored var automaticInstallInFlight = false /// The build Sparkle said it installs (`updaterWillInstallUpdate`): the relaunch's focus record names it. @ObservationIgnored var installingBuild: String? + /// The build whose install the last relaunch left undone, and why (``UpdateInstallCheck``). + public internal(set) var failedInstall: (build: String, failure: UpdateInstallFailure)? /// The frontmost app when the automatic install in flight started (``UpdateRelaunchFocus``). @ObservationIgnored var automaticInstallFrontmost: UpdateFrontmostApp? /// Whether this app is the frontmost one, and the app that is (``UpdateRelaunchFocus``); AppKit in @@ -341,6 +339,7 @@ public final class UpdaterService { } restorePinnedTestFeed() restoreRollbackSkip() + failedInstall = UpdateInstallCheck.launchFailure(defaults: defaults, build: identity.build, now: now(), log: log) releasePush.onNewerBuild = { [weak self] in self?.releasePushed($0) } tipState = TipState(defaults: defaults) // The app settled the install before making the updater (AppServices): an install that ran @@ -453,8 +452,7 @@ public final class UpdaterService { } } - /// Reads the feed and records what it offers this Mac. Joins a probe - /// already in flight. + /// Reads the feed and records what it offers this Mac. Joins a probe already in flight. @discardableResult public func probe() -> Task { if let probeTask { return probeTask } @@ -592,8 +590,7 @@ public final class UpdaterService { } extension UpdaterService { - /// ``switchChannel(to:)`` by name (`stable`, `nightly`); nil means the - /// counterpart of the running app. + /// ``switchChannel(to:)`` by name (`stable`, `nightly`); nil means the counterpart of the running app. @discardableResult public func switchChannel(named name: String?) throws -> Task { let requested = name.flatMap { AppChannelSwitchTarget(rawValue: $0.lowercased()) } @@ -603,8 +600,7 @@ extension UpdaterService { return try switchChannel(to: target) } - /// Why switching channels is impossible here (DEV/RC builds have no - /// counterpart), or nil. + /// Why switching channels is impossible here (DEV/RC builds have no counterpart), or nil. public var channelSwitchUnavailableReason: String? { guard let target = identity.channelSwitchTarget else { return UpdaterUnavailable.cannotSwitch(to: "stable/nightly", from: identity.track).description diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterStrings.swift index fe11a81c6da2..d3dd4f90f1ce 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdaterStrings.swift @@ -29,6 +29,27 @@ nonisolated enum UpdaterStrings { static var preflightSpace: String { text("updater.preflight.space", "There isn't enough free disk space to install the update. Free some space, or download the update manually.") } + // An install that left the old build running (``UpdateInstallFailure``) + static var installFailedTitle: String { text("updater.installFailed.title", "Update Didn't Install") } + static var tryAgain: String { text("updater.card.tryAgain", "Try Again") } + static var installFailedTranslocatedShort: String { + text("updater.installFailed.translocated.short", "cmux runs from a read-only copy") + } + static var installFailedTranslocated: String { + text("updater.installFailed.translocated", "macOS runs this cmux from a read-only copy, so the installer couldn't replace it. Move cmux to Applications and open it from there, or download the update manually.") + } + static var installFailedFolderShort: String { text("updater.installFailed.folder.short", "cmux can't replace itself here") } + static func installFailedFolder(_ folder: String) -> String { + format("updater.installFailed.folder", "The installer couldn't replace cmux in %@. Move cmux to Applications, or download the update manually.", folder) + } + static var installFailedSpaceShort: String { text("updater.installFailed.space.short", "Not enough free disk space") } + static var installFailedSpace: String { + text("updater.installFailed.space", "There wasn't enough free disk space to install the update. Free some space and try again, or download the update manually.") + } + static var installFailedUnknownShort: String { text("updater.installFailed.unknown.short", "The installer kept this version") } + static var installFailedUnknown: String { + text("updater.installFailed.unknown", "The installer kept this version of cmux and gave no reason. Try again, or download the update manually.") + } static var managed: String { text("updater.title.managed", "Updates Are Managed") } static func managedChannel(_ channel: String) -> String { format("updater.managed.channel", "Your organization keeps this Mac on the %@ channel.", channel) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateInstallCheckTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateInstallCheckTests.swift new file mode 100644 index 000000000000..30b3b4c39039 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateInstallCheckTests.swift @@ -0,0 +1,119 @@ +import Foundation +@testable import CmuxNextUpdater +import Testing + +/// An install that fails after the app quit says why (Leo 2026-10-21: "If an installed update ever +/// fails, the app must say why instead of offering Restart again."). The relaunch records which +/// build it installs over which; a launch still on the old build reads that as a failed install, +/// and the staged update card for that build names the cause instead of Restart to Update. +@MainActor @Suite(.serialized) struct UpdateInstallCheckTests { + private static let start = Date(timeIntervalSince1970: 1_800_000_000) + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: "UpdateInstallCheckTests.\(UUID().uuidString)")! + } + + private static func updater(defaults: UserDefaults, build: String = "100", at time: Date = start) -> UpdaterService { + let identity = UpdateBuildIdentity(bundleIdentifier: "com.cmuxterm.app.debug.install-check", shortVersion: "1.0.0", + build: build, minimumSystemVersion: nil, + infoFeedURL: "https://files-next.cmux.com/nightly-next/appcast-arm64.xml", hasPublicKey: false) + return UpdaterService(identity: identity, policy: ManagedUpdatePolicy { false }, defaults: defaults, + enableSparkle: false, recordsUpdates: false, now: { time }) + } + + @Test func aLaunchOnTheOldBuildIsAFailedInstall() { + let record = UpdateInstallCheck(fromBuild: "100", targetBuild: "200", recordedAt: Self.start) + let soon = Self.start.addingTimeInterval(30) + #expect(record.outcome(runningBuild: "200", now: soon) == .installed) + #expect(record.outcome(runningBuild: "100", now: soon) == .failed) + #expect(record.outcome(runningBuild: "300", now: soon) == nil, "a third build was installed by hand") + #expect(record.outcome(runningBuild: "100", now: Self.start.addingTimeInterval(UpdateInstallCheck.maxAge + 1)) == nil, "stale") + #expect(record.outcome(runningBuild: "100", now: Self.start.addingTimeInterval(-3600)) == nil, "written in the future") + let same = UpdateInstallCheck(fromBuild: "100", targetBuild: "100", recordedAt: Self.start) + #expect(same.outcome(runningBuild: "100", now: soon) == nil, "a reinstall of the running build judges nothing") + } + + @Test func consumeReadsTheRecordOnce() { + let defaults = Self.defaults() + UpdateInstallCheck.write(UpdateInstallCheck(fromBuild: "100", targetBuild: "200", recordedAt: Self.start), defaults: defaults) + #expect(UpdateInstallCheck.consume(defaults: defaults, build: "100", now: Self.start)?.outcome == .failed) + #expect(UpdateInstallCheck.consume(defaults: defaults, build: "100", now: Self.start) == nil, "used once") + + defaults.set(Data("not json".utf8), forKey: UpdateInstallCheck.key) + #expect(UpdateInstallCheck.consume(defaults: defaults, build: "100") == nil) + #expect(defaults.data(forKey: UpdateInstallCheck.key) == nil) + } + + @Test func theRelaunchRecordsTheInstallAndTheNextLaunchJudgesIt() throws { + let defaults = Self.defaults() + let before = Self.updater(defaults: defaults) + before.installingBuild = "200" + before.updaterWillRelaunchApplication() + let record = try #require(defaults.data(forKey: UpdateInstallCheck.key).flatMap { try? JSONDecoder().decode(UpdateInstallCheck.self, from: $0) }) + #expect(record == UpdateInstallCheck(fromBuild: "100", targetBuild: "200", recordedAt: Self.start)) + + // Sparkle put the old build back: the relaunch is still 100. + let after = Self.updater(defaults: defaults, at: Self.start.addingTimeInterval(20)) + let failed = try #require(after.failedInstall, "a relaunch on the old build did not notice the failed install") + #expect(failed.build == "200") + #expect(after.log.recent.contains { $0.contains("install of 200 did not replace 100") }) + #expect(defaults.data(forKey: UpdateInstallCheck.key) == nil) + } + + @Test func aRelaunchIntoTheNewBuildIsNoFailure() { + let defaults = Self.defaults() + UpdateInstallCheck.write(UpdateInstallCheck(fromBuild: "100", targetBuild: "200", recordedAt: Self.start), defaults: defaults) + let after = Self.updater(defaults: defaults, build: "200", at: Self.start.addingTimeInterval(20)) + #expect(after.failedInstall == nil) + } + + @Test func anInstallThatEndsWithTheAppRunningLeavesNoRecord() { + let defaults = Self.defaults() + let updater = Self.updater(defaults: defaults) + updater.installingBuild = "200" + updater.updaterWillRelaunchApplication() + updater.abandonInstall() + #expect(defaults.data(forKey: UpdateInstallCheck.key) == nil, "a later launch of this build would read as a failed install") + } + + @Test func theCardForTheFailedBuildSaysWhyInsteadOfRestart() { + let notes = UpdateReadyNotes(version: "nightly 200", notes: nil, fullNotesURL: nil) + let failed = UpdateReadyCard(version: "nightly 200", isInstalling: false, automaticUpdates: true, notes: notes, + failure: .notEnoughSpace(free: 1, needed: 2)) + #expect(failed.title == UpdaterStrings.installFailedTitle) + #expect(failed.detail == UpdaterStrings.installFailedSpaceShort) + #expect(failed.buttonTitle == UpdaterStrings.tryAgain) + #expect(failed.buttonTitle != UpdaterStrings.restartToUpdate) + #expect(failed.notes.headline == UpdaterStrings.installFailedSpace) + + let retrying = UpdateReadyCard(version: "nightly 200", isInstalling: true, automaticUpdates: true, notes: notes, + failure: .notReplaced) + #expect(retrying.title == UpdaterStrings.installingUpdate, "Try Again installs: the card says so") + #expect(retrying.failure == nil) + } + + @Test func onlyTheBuildWhoseInstallFailedCarriesTheReason() { + let failed: (build: String, failure: UpdateInstallFailure)? = ("200", .notReplaced) + #expect(UpdateInstallFailure.forStaged("200", failed: failed) == .notReplaced) + #expect(UpdateInstallFailure.forStaged("201", failed: failed) == nil, "a newer build is a new install, offered as usual") + #expect(UpdateInstallFailure.forStaged(nil, failed: failed) == nil) + #expect(UpdateInstallFailure.forStaged("200", failed: nil) == nil) + } + + @Test func diagnoseNamesTheCauseItCanSee() throws { + let translocated = URL(filePath: "/private/var/folders/xy/T/AppTranslocation/ABC/d/cmux NIGHTLY.app") + #expect(UpdateInstallFailure.diagnose(bundle: translocated) == .translocated) + + let root = FileManager.default.temporaryDirectory.appending(path: "UpdateInstallCheckTests-\(UUID().uuidString)") + let bundle = root.appending(path: "cmux.app") + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.path) + try? FileManager.default.removeItem(at: root) + } + #expect(UpdateInstallFailure.diagnose(bundle: bundle) == .notReplaced) + + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: root.path) + #expect(UpdateInstallFailure.diagnose(bundle: bundle) == .folderNotWritable(root.path)) + } +}