From 5f93259bf5d7877aedd342bc7ca6df4d7eaaa92f Mon Sep 17 00:00:00 2001 From: phantomptr Date: Fri, 9 Oct 2026 11:29:37 -0700 Subject: [PATCH] Fixes from issues and Discord: a full drive's partial file, stopping an install early, 0x80B21104, VPN - A single-file upload that fills the drive keeps .ava-part so Retry resumes it, but nothing said so: the failed row now names it and offers Delete partial file (the next attempt starts fresh). - Stopping a stream install before the engine had its job dropped the row and left the install running unseen; Stop now waits for the job and stops it. - 0x80B21104 explains that the console will not take this package and links its FAQ entry, instead of the generic PlayGo text. - The Connection screen's "port not open" step suggests turning a VPN off (reported twice on Discord). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9 --- client/src/i18n/locales/ar.ts | 6 +++ client/src/i18n/locales/bn.ts | 6 +++ client/src/i18n/locales/de.ts | 6 +++ client/src/i18n/locales/en.ts | 6 +++ client/src/i18n/locales/es.ts | 6 +++ client/src/i18n/locales/fa.ts | 6 +++ client/src/i18n/locales/fr.ts | 6 +++ client/src/i18n/locales/hi.ts | 6 +++ client/src/i18n/locales/hu.ts | 6 +++ client/src/i18n/locales/id.ts | 6 +++ client/src/i18n/locales/it.ts | 6 +++ client/src/i18n/locales/ja.ts | 6 +++ client/src/i18n/locales/ko.ts | 6 +++ client/src/i18n/locales/pl.ts | 6 +++ client/src/i18n/locales/pt-BR.ts | 6 +++ client/src/i18n/locales/ru.ts | 6 +++ client/src/i18n/locales/th.ts | 6 +++ client/src/i18n/locales/tr.ts | 6 +++ client/src/i18n/locales/vi.ts | 6 +++ client/src/i18n/locales/zh-CN.ts | 6 +++ client/src/i18n/locales/zh-TW.ts | 6 +++ client/src/lib/humanizeError.test.ts | 6 +++ client/src/lib/humanizeError.ts | 5 ++ client/src/lib/installErrorDoc.test.ts | 2 + client/src/lib/installErrorDoc.ts | 2 + client/src/lib/partialUpload.test.ts | 27 ++++++++++ client/src/lib/partialUpload.ts | 17 +++++++ client/src/screens/Connection/index.tsx | 4 +- client/src/screens/Upload/QueuePanel.tsx | 63 ++++++++++++++++++++++++ client/src/state/uploadQueue.test.ts | 19 +++++++ client/src/state/uploadQueue.ts | 39 +++++++++++++-- 31 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 client/src/lib/partialUpload.test.ts create mode 100644 client/src/lib/partialUpload.ts diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts index 0fa408d9..31d54579 100644 --- a/client/src/i18n/locales/ar.ts +++ b/client/src/i18n/locales/ar.ts @@ -3747,6 +3747,12 @@ activity_showing_console: "يُعرض {name}", activity_show_this_console: "اعرض هذا الجهاز", game_open_page: "افتح صفحة اللعبة", game_already_on: "{name} لديه هذا بالفعل", +queue_partial_kept: "يُحتفظ بما أُرسل على PS5 باسم {path}، لكي تُكمل إعادة المحاولة من حيث توقفت. يشغل مساحة: احذفه إن لم تعد المحاولة.", +queue_partial_delete: "احذف الملف الجزئي", +queue_partial_delete_title: "حذف الملف الجزئي؟", +queue_partial_delete_body: "سيُحذف {path} من PS5، ثم يبدأ الرفع من البداية.", +queue_partial_deleted: "حُذف الملف الجزئي. إعادة المحاولة تبدأ الرفع من جديد.", +connection_vpn_hint: "إن كانت شبكة VPN تعمل، أوقفها (أو اسمح فيها بالوصول إلى الشبكة المحلية) ثم تحقق مجددًا.", }; export default ar; diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts index 1e483450..d8965530 100644 --- a/client/src/i18n/locales/bn.ts +++ b/client/src/i18n/locales/bn.ts @@ -3747,6 +3747,12 @@ activity_showing_console: "{name} দেখানো হচ্ছে", activity_show_this_console: "এই কনসোল দেখান", game_open_page: "গেম পেজ খুলুন", game_already_on: "{name}-এ এটি আগে থেকেই আছে", +queue_partial_kept: "যা পাঠানো হয়েছে তা PS5-এ {path} হিসেবে রাখা আছে, যাতে আবার চেষ্টা থামার জায়গা থেকে চালিয়ে যায়। এটি জায়গা নেয়: আবার চেষ্টা না করলে মুছে ফেলুন।", +queue_partial_delete: "অসম্পূর্ণ ফাইল মুছুন", +queue_partial_delete_title: "অসম্পূর্ণ ফাইল মুছবেন?", +queue_partial_delete_body: "{path} PS5 থেকে মুছে ফেলা হবে। তারপর আপলোড শুরু থেকে হবে।", +queue_partial_deleted: "অসম্পূর্ণ ফাইল মুছে ফেলা হয়েছে। আবার চেষ্টা শুরু থেকে আপলোড করবে।", +connection_vpn_hint: "VPN চালু থাকলে বন্ধ করুন (বা এতে লোকাল নেটওয়ার্ক অ্যাক্সেস দিন), তারপর আবার যাচাই করুন।", }; export default bn; diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts index 0c7edf58..c4f13d47 100644 --- a/client/src/i18n/locales/de.ts +++ b/client/src/i18n/locales/de.ts @@ -3755,6 +3755,12 @@ activity_showing_console: "{name} wird angezeigt", activity_show_this_console: "Diese Konsole anzeigen", game_open_page: "Spielseite öffnen", game_already_on: "{name} hat es bereits", +queue_partial_kept: "Das bereits Gesendete bleibt auf der PS5 als {path}, damit „Erneut versuchen“ dort weitermacht. Es belegt Speicher: Lösche es, wenn du es nicht erneut versuchst.", +queue_partial_delete: "Teildatei löschen", +queue_partial_delete_title: "Teildatei löschen?", +queue_partial_delete_body: "{path} wird von der PS5 gelöscht. Der Upload beginnt dann von vorn.", +queue_partial_deleted: "Teildatei gelöscht. „Erneut versuchen“ beginnt den Upload von vorn.", +connection_vpn_hint: "Wenn ein VPN aktiv ist, schalte es aus (oder erlaube darin den Zugriff aufs lokale Netzwerk) und prüfe erneut.", }; export default de; diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index a2e0ae71..9e9971af 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -4229,6 +4229,12 @@ activity_showing_console: "Showing {name}", activity_show_this_console: "Show this console", game_open_page: "Open game page", game_already_on: "{name} has it already", +queue_partial_kept: "What was sent is kept on the PS5 as {path}, so Retry continues where it stopped. It takes up space: delete it if you won't retry.", +queue_partial_delete: "Delete partial file", +queue_partial_delete_title: "Delete the partial file?", +queue_partial_delete_body: "{path} will be deleted from the PS5. The upload then starts over from the beginning.", +queue_partial_deleted: "Partial file deleted. Retry starts the upload over.", +connection_vpn_hint: "If a VPN is on, turn it off (or allow local network access in it), then check again.", }; export default en; diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts index f3c74907..58ab207f 100644 --- a/client/src/i18n/locales/es.ts +++ b/client/src/i18n/locales/es.ts @@ -3694,6 +3694,12 @@ activity_showing_console: "Mostrando {name}", activity_show_this_console: "Mostrar esta consola", game_open_page: "Abrir la página del juego", game_already_on: "{name} ya lo tiene", +queue_partial_kept: "Lo enviado se guarda en la PS5 como {path}, para que Reintentar continúe donde se detuvo. Ocupa espacio: bórralo si no vas a reintentar.", +queue_partial_delete: "Borrar archivo parcial", +queue_partial_delete_title: "¿Borrar el archivo parcial?", +queue_partial_delete_body: "{path} se borrará de la PS5. La subida empezará de nuevo desde el principio.", +queue_partial_deleted: "Archivo parcial borrado. Reintentar empieza la subida de nuevo.", +connection_vpn_hint: "Si hay una VPN activa, desactívala (o permite en ella el acceso a la red local) y vuelve a comprobar.", }; export default es; diff --git a/client/src/i18n/locales/fa.ts b/client/src/i18n/locales/fa.ts index ee0e189b..a063fdda 100644 --- a/client/src/i18n/locales/fa.ts +++ b/client/src/i18n/locales/fa.ts @@ -4123,6 +4123,12 @@ activity_showing_console: "نمایش {name}", activity_show_this_console: "نمایش این کنسول", game_open_page: "باز کردن صفحه بازی", game_already_on: "{name} این را از قبل دارد", +queue_partial_kept: "بخشِ ارسال‌شده روی PS5 با نام {path} نگه داشته می‌شود تا «تلاش دوباره» از همان‌جا ادامه دهد. فضا می‌گیرد؛ اگر دوباره تلاش نمی‌کنید حذفش کنید.", +queue_partial_delete: "حذف فایل ناقص", +queue_partial_delete_title: "فایل ناقص حذف شود؟", +queue_partial_delete_body: "{path} از PS5 حذف می‌شود و آپلود از ابتدا شروع می‌شود.", +queue_partial_deleted: "فایل ناقص حذف شد. «تلاش دوباره» آپلود را از ابتدا شروع می‌کند.", +connection_vpn_hint: "اگر VPN روشن است، آن را خاموش کنید (یا در آن دسترسی به شبکه محلی را مجاز کنید) و دوباره بررسی کنید.", }; export default fa; diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts index ce6f9daf..f1f76f0a 100644 --- a/client/src/i18n/locales/fr.ts +++ b/client/src/i18n/locales/fr.ts @@ -3781,6 +3781,12 @@ activity_showing_console: "Affichage de {name}", activity_show_this_console: "Afficher cette console", game_open_page: "Ouvrir la page du jeu", game_already_on: "{name} l'a déjà", +queue_partial_kept: "Ce qui a été envoyé est gardé sur la PS5 sous {path}, pour que Réessayer reprenne là où ça s'est arrêté. Il prend de la place : supprimez-le si vous ne réessayez pas.", +queue_partial_delete: "Supprimer le fichier partiel", +queue_partial_delete_title: "Supprimer le fichier partiel ?", +queue_partial_delete_body: "{path} sera supprimé de la PS5. L'envoi reprendra alors depuis le début.", +queue_partial_deleted: "Fichier partiel supprimé. Réessayer recommence l'envoi.", +connection_vpn_hint: "Si un VPN est actif, désactivez-le (ou autorisez-y l'accès au réseau local), puis vérifiez à nouveau.", }; export default fr; diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts index 1553dc44..c8a3d7d7 100644 --- a/client/src/i18n/locales/hi.ts +++ b/client/src/i18n/locales/hi.ts @@ -3780,6 +3780,12 @@ activity_showing_console: "{name} दिखाया जा रहा है", activity_show_this_console: "यह कंसोल दिखाएँ", game_open_page: "गेम पेज खोलें", game_already_on: "{name} पर यह पहले से है", +queue_partial_kept: "भेजा गया हिस्सा PS5 पर {path} के रूप में रखा गया है, ताकि फिर से कोशिश वहीं से जारी रखे। यह जगह घेरता है: अगर फिर से कोशिश नहीं करनी तो इसे हटा दें।", +queue_partial_delete: "अधूरी फ़ाइल हटाएँ", +queue_partial_delete_title: "अधूरी फ़ाइल हटाएँ?", +queue_partial_delete_body: "{path} PS5 से हटा दी जाएगी। फिर अपलोड शुरू से होगा।", +queue_partial_deleted: "अधूरी फ़ाइल हटा दी गई। फिर से कोशिश अपलोड शुरू से करेगा।", +connection_vpn_hint: "अगर VPN चालू है तो उसे बंद करें (या उसमें लोकल नेटवर्क की अनुमति दें), फिर दोबारा जाँचें।", }; export default hi; diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts index 89b73345..bc3dd058 100644 --- a/client/src/i18n/locales/hu.ts +++ b/client/src/i18n/locales/hu.ts @@ -4100,6 +4100,12 @@ activity_showing_console: "{name} látható", activity_show_this_console: "Ennek a konzolnak a megjelenítése", game_open_page: "Játékoldal megnyitása", game_already_on: "A(z) {name} már tartalmazza", +queue_partial_kept: "Az elküldött rész a PS5-ön marad {path} néven, így az Újra onnan folytatja, ahol abbamaradt. Helyet foglal: töröld, ha nem próbálod újra.", +queue_partial_delete: "Részleges fájl törlése", +queue_partial_delete_title: "Törlöd a részleges fájlt?", +queue_partial_delete_body: "A(z) {path} törlődik a PS5-ről. A feltöltés ezután elölről kezdődik.", +queue_partial_deleted: "Részleges fájl törölve. Az Újra elölről kezdi a feltöltést.", +connection_vpn_hint: "Ha VPN van bekapcsolva, kapcsold ki (vagy engedélyezd benne a helyi hálózat elérését), majd ellenőrizd újra.", }; export default hu; diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts index f529c3f3..5360b3a2 100644 --- a/client/src/i18n/locales/id.ts +++ b/client/src/i18n/locales/id.ts @@ -3756,6 +3756,12 @@ activity_showing_console: "Menampilkan {name}", activity_show_this_console: "Tampilkan konsol ini", game_open_page: "Buka halaman game", game_already_on: "{name} sudah memilikinya", +queue_partial_kept: "Yang sudah terkirim disimpan di PS5 sebagai {path}, agar Coba lagi melanjutkan dari titik berhenti. File ini memakan ruang: hapus jika tidak akan mencoba lagi.", +queue_partial_delete: "Hapus file parsial", +queue_partial_delete_title: "Hapus file parsial?", +queue_partial_delete_body: "{path} akan dihapus dari PS5. Unggahan lalu dimulai dari awal.", +queue_partial_deleted: "File parsial dihapus. Coba lagi memulai unggahan dari awal.", +connection_vpn_hint: "Jika VPN aktif, matikan (atau izinkan akses jaringan lokal di dalamnya), lalu periksa lagi.", }; export default id; diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts index b2df051f..8b0fe47e 100644 --- a/client/src/i18n/locales/it.ts +++ b/client/src/i18n/locales/it.ts @@ -3694,6 +3694,12 @@ activity_showing_console: "Mostra {name}", activity_show_this_console: "Mostra questa console", game_open_page: "Apri la pagina del gioco", game_already_on: "{name} ce l'ha già", +queue_partial_kept: "Quanto inviato resta sulla PS5 come {path}, così Riprova continua da dove si era fermato. Occupa spazio: eliminalo se non riproverai.", +queue_partial_delete: "Elimina file parziale", +queue_partial_delete_title: "Eliminare il file parziale?", +queue_partial_delete_body: "{path} verrà eliminato dalla PS5. Il caricamento ripartirà dall'inizio.", +queue_partial_deleted: "File parziale eliminato. Riprova ricomincia il caricamento.", +connection_vpn_hint: "Se è attiva una VPN, disattivala (o consenti in essa l'accesso alla rete locale), poi controlla di nuovo.", }; export default it; diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts index a13b020f..8f347020 100644 --- a/client/src/i18n/locales/ja.ts +++ b/client/src/i18n/locales/ja.ts @@ -3785,6 +3785,12 @@ activity_showing_console: "{name} を表示中", activity_show_this_console: "この本体を表示", game_open_page: "ゲームページを開く", game_already_on: "{name} にはすでにあります", +queue_partial_kept: "送信済みの部分は PS5 上に {path} として残り、再試行で続きから送れます。容量を使うので、再試行しない場合は削除してください。", +queue_partial_delete: "途中のファイルを削除", +queue_partial_delete_title: "途中のファイルを削除しますか?", +queue_partial_delete_body: "{path} を PS5 から削除します。アップロードは最初からやり直しになります。", +queue_partial_deleted: "途中のファイルを削除しました。再試行すると最初からアップロードします。", +connection_vpn_hint: "VPN がオンならオフにして (または VPN でローカルネットワークへのアクセスを許可して)、もう一度確認してください。", }; export default ja; diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts index 23fa239c..1b162ab0 100644 --- a/client/src/i18n/locales/ko.ts +++ b/client/src/i18n/locales/ko.ts @@ -3782,6 +3782,12 @@ activity_showing_console: "{name} 표시 중", activity_show_this_console: "이 콘솔 표시", game_open_page: "게임 페이지 열기", game_already_on: "{name}에 이미 있습니다", +queue_partial_kept: "보낸 부분은 PS5에 {path}(으)로 남아 있어 다시 시도하면 멈춘 곳부터 이어집니다. 공간을 차지하니 다시 시도하지 않을 거라면 삭제하세요.", +queue_partial_delete: "부분 파일 삭제", +queue_partial_delete_title: "부분 파일을 삭제할까요?", +queue_partial_delete_body: "{path}이(가) PS5에서 삭제됩니다. 업로드는 처음부터 다시 시작됩니다.", +queue_partial_deleted: "부분 파일을 삭제했습니다. 다시 시도하면 처음부터 업로드합니다.", +connection_vpn_hint: "VPN이 켜져 있다면 끄거나(또는 VPN에서 로컬 네트워크 접근을 허용하고) 다시 확인하세요.", }; export default ko; diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts index 487d99a0..a2575a01 100644 --- a/client/src/i18n/locales/pl.ts +++ b/client/src/i18n/locales/pl.ts @@ -4033,6 +4033,12 @@ activity_showing_console: "Wyświetlono: {name}", activity_show_this_console: "Pokaż tę konsolę", game_open_page: "Otwórz stronę gry", game_already_on: "{name} już to ma", +queue_partial_kept: "Wysłana część zostaje na PS5 jako {path}, aby Ponów kontynuowało od miejsca przerwania. Zajmuje miejsce: usuń ją, jeśli nie ponowisz.", +queue_partial_delete: "Usuń częściowy plik", +queue_partial_delete_title: "Usunąć częściowy plik?", +queue_partial_delete_body: "{path} zostanie usunięty z PS5. Przesyłanie zacznie się od początku.", +queue_partial_deleted: "Częściowy plik usunięty. Ponów zaczyna przesyłanie od nowa.", +connection_vpn_hint: "Jeśli VPN jest włączony, wyłącz go (lub zezwól w nim na dostęp do sieci lokalnej) i sprawdź ponownie.", }; export default pl; diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts index ac23cb1d..2e9ce256 100644 --- a/client/src/i18n/locales/pt-BR.ts +++ b/client/src/i18n/locales/pt-BR.ts @@ -3779,6 +3779,12 @@ activity_showing_console: "Mostrando {name}", activity_show_this_console: "Mostrar este console", game_open_page: "Abrir a página do jogo", game_already_on: "O {name} já tem", +queue_partial_kept: "O que foi enviado fica no PS5 como {path}, para que Tentar novamente continue de onde parou. Ele ocupa espaço: apague-o se não for tentar de novo.", +queue_partial_delete: "Apagar arquivo parcial", +queue_partial_delete_title: "Apagar o arquivo parcial?", +queue_partial_delete_body: "{path} será apagado do PS5. O envio recomeça do início.", +queue_partial_deleted: "Arquivo parcial apagado. Tentar novamente recomeça o envio.", +connection_vpn_hint: "Se houver uma VPN ativa, desligue-a (ou permita nela o acesso à rede local) e verifique de novo.", }; export default pt_BR; diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts index c85de8da..150405cf 100644 --- a/client/src/i18n/locales/ru.ts +++ b/client/src/i18n/locales/ru.ts @@ -3694,6 +3694,12 @@ activity_showing_console: "Показано: {name}", activity_show_this_console: "Показать эту консоль", game_open_page: "Открыть страницу игры", game_already_on: "На {name} это уже есть", +queue_partial_kept: "Отправленное сохранено на PS5 как {path}, чтобы «Повторить» продолжило с места остановки. Оно занимает место: удалите его, если не будете повторять.", +queue_partial_delete: "Удалить частичный файл", +queue_partial_delete_title: "Удалить частичный файл?", +queue_partial_delete_body: "{path} будет удалён с PS5. Загрузка начнётся заново.", +queue_partial_deleted: "Частичный файл удалён. «Повторить» начнёт загрузку заново.", +connection_vpn_hint: "Если включён VPN, выключите его (или разрешите в нём доступ к локальной сети) и проверьте снова.", }; export default ru; diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts index 8eb87ec1..a803b181 100644 --- a/client/src/i18n/locales/th.ts +++ b/client/src/i18n/locales/th.ts @@ -3771,6 +3771,12 @@ activity_showing_console: "กำลังแสดง {name}", activity_show_this_console: "แสดงคอนโซลนี้", game_open_page: "เปิดหน้าเกม", game_already_on: "{name} มีอยู่แล้ว", +queue_partial_kept: "ส่วนที่ส่งไปแล้วถูกเก็บไว้บน PS5 เป็น {path} เพื่อให้ลองอีกครั้งทำต่อจากจุดที่หยุด ไฟล์นี้ใช้พื้นที่ ลบได้หากจะไม่ลองอีก", +queue_partial_delete: "ลบไฟล์ที่ยังไม่ครบ", +queue_partial_delete_title: "ลบไฟล์ที่ยังไม่ครบหรือไม่?", +queue_partial_delete_body: "{path} จะถูกลบจาก PS5 แล้วการอัปโหลดจะเริ่มใหม่ตั้งแต่ต้น", +queue_partial_deleted: "ลบไฟล์ที่ยังไม่ครบแล้ว ลองอีกครั้งจะเริ่มอัปโหลดใหม่", +connection_vpn_hint: "หากเปิด VPN อยู่ ให้ปิด (หรืออนุญาตการเข้าถึงเครือข่ายภายในใน VPN) แล้วตรวจอีกครั้ง", }; export default th; diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts index 76f5de6b..bcbbb515 100644 --- a/client/src/i18n/locales/tr.ts +++ b/client/src/i18n/locales/tr.ts @@ -3754,6 +3754,12 @@ activity_showing_console: "{name} gösteriliyor", activity_show_this_console: "Bu konsolu göster", game_open_page: "Oyun sayfasını aç", game_already_on: "{name} üzerinde zaten var", +queue_partial_kept: "Gönderilen kısım PS5'te {path} olarak tutulur, böylece Yeniden dene kaldığı yerden devam eder. Yer kaplar: yeniden denemeyeceksen sil.", +queue_partial_delete: "Kısmi dosyayı sil", +queue_partial_delete_title: "Kısmi dosya silinsin mi?", +queue_partial_delete_body: "{path} PS5'ten silinecek. Yükleme baştan başlar.", +queue_partial_deleted: "Kısmi dosya silindi. Yeniden dene yüklemeyi baştan başlatır.", +connection_vpn_hint: "Bir VPN açıksa kapat (ya da içinde yerel ağ erişimine izin ver), sonra yeniden kontrol et.", }; export default tr; diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts index 830dca36..ca694f46 100644 --- a/client/src/i18n/locales/vi.ts +++ b/client/src/i18n/locales/vi.ts @@ -3749,6 +3749,12 @@ activity_showing_console: "Đang hiển thị {name}", activity_show_this_console: "Hiển thị máy này", game_open_page: "Mở trang trò chơi", game_already_on: "{name} đã có rồi", +queue_partial_kept: "Phần đã gửi được giữ trên PS5 dưới tên {path}, để Thử lại tiếp tục từ chỗ dừng. Nó chiếm dung lượng: hãy xóa nếu bạn không thử lại.", +queue_partial_delete: "Xóa tệp dở dang", +queue_partial_delete_title: "Xóa tệp dở dang?", +queue_partial_delete_body: "{path} sẽ bị xóa khỏi PS5. Việc tải lên sẽ bắt đầu lại từ đầu.", +queue_partial_deleted: "Đã xóa tệp dở dang. Thử lại sẽ tải lên lại từ đầu.", +connection_vpn_hint: "Nếu đang bật VPN, hãy tắt nó (hoặc cho phép truy cập mạng cục bộ trong VPN), rồi kiểm tra lại.", }; export default vi; diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 8bc464d8..90a13afc 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -3700,6 +3700,12 @@ activity_showing_console: "正在显示 {name}", activity_show_this_console: "显示此主机", game_open_page: "打开游戏页面", game_already_on: "{name} 上已经有了", +queue_partial_kept: "已发送的部分以 {path} 保留在 PS5 上,重试时会从中断处继续。它会占用空间:如果不打算重试,请删除它。", +queue_partial_delete: "删除未完成的文件", +queue_partial_delete_title: "删除未完成的文件?", +queue_partial_delete_body: "将从 PS5 删除 {path}。之后上传会从头开始。", +queue_partial_deleted: "已删除未完成的文件。重试会从头开始上传。", +connection_vpn_hint: "如果开着 VPN,请关闭它(或在 VPN 中允许访问本地网络),然后再检查一次。", }; export default zh_CN; diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts index 349a391c..cd838a07 100644 --- a/client/src/i18n/locales/zh-TW.ts +++ b/client/src/i18n/locales/zh-TW.ts @@ -3694,6 +3694,12 @@ activity_showing_console: "正在顯示 {name}", activity_show_this_console: "顯示此主機", game_open_page: "開啟遊戲頁面", game_already_on: "{name} 上已經有了", +queue_partial_kept: "已傳送的部分以 {path} 保留在 PS5 上,重試時會從中斷處繼續。它會佔用空間:如果不打算重試,請刪除它。", +queue_partial_delete: "刪除未完成的檔案", +queue_partial_delete_title: "刪除未完成的檔案?", +queue_partial_delete_body: "將從 PS5 刪除 {path}。之後上傳會從頭開始。", +queue_partial_deleted: "已刪除未完成的檔案。重試會從頭開始上傳。", +connection_vpn_hint: "如果開著 VPN,請關閉它(或在 VPN 中允許存取本地網路),然後再檢查一次。", }; export default zh_TW; diff --git a/client/src/lib/humanizeError.test.ts b/client/src/lib/humanizeError.test.ts index 1b71bf6c..5bcc39da 100644 --- a/client/src/lib/humanizeError.test.ts +++ b/client/src/lib/humanizeError.test.ts @@ -349,3 +349,9 @@ describe("humanizePs5Error", () => { }); }); }); + +describe("0x80B21104", () => { + it("says the console would not take this package, not a generic PlayGo error", () => { + expect(humanizePs5Error("The PS5 declined the install. (0x80b21104)")).toContain("would not accept this package"); + }); +}); diff --git a/client/src/lib/humanizeError.ts b/client/src/lib/humanizeError.ts index 608ca8f2..991c47fe 100644 --- a/client/src/lib/humanizeError.ts +++ b/client/src/lib/humanizeError.ts @@ -420,6 +420,11 @@ export function humanizePs5Error( if (/0x80B22101/i.test(raw)) { return te("err_install_2101"); } + // ─── 0x80B2_1104 — the installer would not take this package ────── + // Before the wildcard: it is the package, not PlayGo (FAQ "0x80B21104"). + if (/0x80B2_?1104/i.test(raw)) { + return te("pkg.sony_80b21104"); + } if (/\b0x80B2[0-9A-Fa-f]{4}\b/i.test(raw)) { // Other 0x80B2_xxxx — PlayGo errors, not pkg-format errors. // Don't blame the file. diff --git a/client/src/lib/installErrorDoc.test.ts b/client/src/lib/installErrorDoc.test.ts index 2c1d68c6..73426ff7 100644 --- a/client/src/lib/installErrorDoc.test.ts +++ b/client/src/lib/installErrorDoc.test.ts @@ -24,6 +24,8 @@ describe("installErrorDoc", () => { ["missing base entitlement", undefined, DOC_ANCHORS.entitlement], ["shows View product instead of Play", undefined, DOC_ANCHORS.viewProduct], ["This PS4 game isn't playable on PS5", undefined, DOC_ANCHORS.ps4OnPs5], + ["The PS5 declined the install. (0x80b21104)", undefined, DOC_ANCHORS.declined1104], + ["x", 0x80b21104, DOC_ANCHORS.declined1104], ]; for (const [raw, code, query] of cases) { expect(installErrorDoc(raw, code)?.query, `${raw} / ${code}`).toBe(query); diff --git a/client/src/lib/installErrorDoc.ts b/client/src/lib/installErrorDoc.ts index 33f18246..6078218d 100644 --- a/client/src/lib/installErrorDoc.ts +++ b/client/src/lib/installErrorDoc.ts @@ -25,9 +25,11 @@ export const DOC_ANCHORS = { ce: "CE-108255-1", wontLaunch: "won't launch", matrix: "support matrix", + declined1104: "0x80B21104", } as const; const RULES: Array<[RegExp, string]> = [ + [/0x80b21104/i, DOC_ANCHORS.declined1104], [/0x80b2116f|0x80b2150f|staged route|refused this package from its own storage/i, DOC_ANCHORS.stagedRefused], [/0x80431084|proxy server|proxy setting/i, DOC_ANCHORS.proxy], [ diff --git a/client/src/lib/partialUpload.test.ts b/client/src/lib/partialUpload.test.ts new file mode 100644 index 00000000..880437ab --- /dev/null +++ b/client/src/lib/partialUpload.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { partialUploadPath } from "./partialUpload"; + +const item = (over: Record) => + ({ + status: "failed", + sourceKind: "image", + resolvedDest: "/data/homebrew/PPSA03671 Wolverine.ffpfsc", + errorReason: "ava1_no_space", + ...over, + }) as Parameters[0]; + +describe("partialUploadPath", () => { + it("names the partial file a single-file upload left when the drive filled", () => { + expect(partialUploadPath(item({}))).toBe("/data/homebrew/PPSA03671 Wolverine.ffpfsc.ava-part"); + expect(partialUploadPath(item({ sourceKind: "file", errorReason: "preflight_insufficient_space" }))).toBe( + "/data/homebrew/PPSA03671 Wolverine.ffpfsc.ava-part", + ); + }); + it("is null for other failures, folders and archives", () => { + expect(partialUploadPath(item({ errorReason: "ava1_connect" }))).toBeNull(); + expect(partialUploadPath(item({ sourceKind: "folder" }))).toBeNull(); + expect(partialUploadPath(item({ sourceKind: "archive" }))).toBeNull(); + expect(partialUploadPath(item({ status: "done" }))).toBeNull(); + }); +}); diff --git a/client/src/lib/partialUpload.ts b/client/src/lib/partialUpload.ts new file mode 100644 index 00000000..1326ff32 --- /dev/null +++ b/client/src/lib/partialUpload.ts @@ -0,0 +1,17 @@ +// A single-file upload writes `.ava-part` on the console and renames it when it +// is complete. When the drive fills up the partial file is kept so Retry resumes it, but it +// holds its whole size: a user who gives up (or wants to free space another way) has to know +// it is there to delete it. + +import type { QueueItem } from "../state/uploadQueue"; + +const NO_SPACE = new Set(["ava1_no_space", "preflight_insufficient_space"]); + +/** The partial file a failed single-file upload left on the console, or null. */ +export function partialUploadPath( + it: Pick, +): string | null { + if (it.status !== "failed" || !it.errorReason || !NO_SPACE.has(it.errorReason)) return null; + if (it.sourceKind !== "file" && it.sourceKind !== "image") return null; + return it.resolvedDest ? `${it.resolvedDest}.ava-part` : null; +} diff --git a/client/src/screens/Connection/index.tsx b/client/src/screens/Connection/index.tsx index 1783c501..a3e3b03d 100644 --- a/client/src/screens/Connection/index.tsx +++ b/client/src/screens/Connection/index.tsx @@ -507,7 +507,9 @@ export default function ConnectionScreen() { ) : looksLikeMacLocalNetworkBlock(probe.error, ua) ? `${detail}. ${tr("connection_mac_local_network_hint", undefined, "macOS may be blocking this app from your local network. Open System Settings → Privacy & Security → Local Network, allow PS5Upload, then check again.")}` - : detail, + : // A VPN on this computer sends the console's address out through the tunnel: the + // likeliest cause when everything else is right (reported twice on Discord). + `${detail}. ${tr("connection_vpn_hint", undefined, "If a VPN is on, turn it off (or allow local network access in it), then check again.")}`, target, ); } diff --git a/client/src/screens/Upload/QueuePanel.tsx b/client/src/screens/Upload/QueuePanel.tsx index f99f6d09..b5f0add3 100644 --- a/client/src/screens/Upload/QueuePanel.tsx +++ b/client/src/screens/Upload/QueuePanel.tsx @@ -55,6 +55,7 @@ import { BottleneckLine, JobLiveNotes, UnsettledLine } from "./Bottleneck"; import { WhySlowPanel } from "./WhySlow"; import { RarPasswordPrompt } from "./RarPasswordPrompt"; import { rarPasswordProblem } from "../../lib/rarPassword"; +import { partialUploadPath } from "../../lib/partialUpload"; import { isStreamUnreachableError } from "../../lib/networkFix"; import { NetworkFixActions } from "../InstallPackage/NetworkFixActions"; @@ -1111,6 +1112,7 @@ export function QueueRow({ detail={item.errorDetail} /> )} + {partialUploadPath(item) && } {item.status === "failed" && isStreamUnreachableError(item.error) && ( )} @@ -1396,6 +1398,67 @@ function FailedRowErrorCard({ ); } +/** A single-file upload that filled the drive keeps what it sent as `.ava-part`, so + * Retry continues it; that file holds the space, so it is named here with a way to delete it. */ +function PartialFileNote({ id, path }: { id: string; path: string }) { + const tr = useTr(); + const { confirm, dialog } = useConfirm(); + const [state, setState] = useState<"idle" | "busy" | "gone">("idle"); + const [failed, setFailed] = useState(null); + if (state === "gone") { + return ( +

+ {tr("queue_partial_deleted", undefined, "Partial file deleted. Retry starts the upload over.")} +

+ ); + } + const remove = async () => { + const ok = await confirm({ + title: tr("queue_partial_delete_title", undefined, "Delete the partial file?"), + message: tr( + "queue_partial_delete_body", + { path }, + "{path} will be deleted from the PS5. The upload then starts over from the beginning.", + ), + confirmLabel: tr("queue_partial_delete", undefined, "Delete partial file"), + destructive: true, + }); + if (!ok) return; + setState("busy"); + setFailed(null); + try { + await useUploadQueueStore.getState().discardPartial(id); + setState("gone"); + } catch (e) { + setState("idle"); + setFailed(e instanceof Error ? e.message : String(e)); + } + }; + return ( +
+ {dialog} +

+ {tr( + "queue_partial_kept", + { path }, + "What was sent is kept on the PS5 as {path}, so Retry continues where it stopped. It takes up space: delete it if you won't retry.", + )} +

+ + {failed &&

{failed}

} +
+ ); +} + function StatusIcon({ status }: { status: QueueItemStatus }) { switch (status) { case "pending": diff --git a/client/src/state/uploadQueue.test.ts b/client/src/state/uploadQueue.test.ts index b491f407..a0c87482 100644 --- a/client/src/state/uploadQueue.test.ts +++ b/client/src/state/uploadQueue.test.ts @@ -1300,6 +1300,25 @@ describe("install items", () => { await waitFor(() => !useUploadQueueStore.getState().items.some((i) => i.id === q.id)); }); + it("an install stopped before the engine has its job is stopped once the job comes", async () => { + const api = await import("../api/ps5"); + vi.mocked(api.pkgInstallStop).mockClear(); + const q = useUploadQueueStore.getState().enqueueInstall({ + host, request: { via: "stream", source: "/early.pkg" }, displayName: "E", + }); + await waitFor(() => calls.length === 1); + useUploadQueueStore.getState().cancelItem(q.id); + expect(vi.mocked(api.pkgInstallStop)).not.toHaveBeenCalled(); + // The engine answers with its job a moment later: that job is the one stopped. + useUploadQueueStore.setState((st) => ({ + items: st.items.map((i) => (i.id === q.id ? { ...i, installJobId: "job-late" } : i)), + })); + await waitFor(() => vi.mocked(api.pkgInstallStop).mock.calls.length > 0); + expect(vi.mocked(api.pkgInstallStop)).toHaveBeenCalledWith("job-late"); + gate.get("/early.pkg")!({ ok: false, message: "stopped" }); + await waitFor(() => !useUploadQueueStore.getState().items.some((i) => i.id === q.id)); + }); + it("an install leaves uploads waiting for Start alone (#410)", async () => { const s = useUploadQueueStore.getState(); s.add({ diff --git a/client/src/state/uploadQueue.ts b/client/src/state/uploadQueue.ts index a0363c5e..a9247ec7 100644 --- a/client/src/state/uploadQueue.ts +++ b/client/src/state/uploadQueue.ts @@ -63,6 +63,7 @@ import { withConsolePrefix } from "./roster"; import { hostOf, mgmtAddr } from "../lib/addr"; import { log } from "./logs"; import { isRemotePath } from "../lib/remotePath"; +import { partialUploadPath } from "../lib/partialUpload"; import { materializeRemote, releaseCopy } from "../lib/materialize"; /** Local copies of server archives, by queue item: made when the item's turn comes (not at @@ -423,6 +424,9 @@ interface QueueState { * the person just typed. The password lives on the in-memory item only (the save redacts * it) and is never logged. False when the password is empty or the row is not a failed one. */ retryWithPassword: (id: string, password: string) => boolean; + /** Deletes the partial file a single-file upload left when the drive filled up, and gives + * the row a new job id so its next attempt starts fresh. Throws with the console's reason. */ + discardPartial: (id: string) => Promise; /** Re-drive one console's uploads that FAILED on a recoverable * (connection-class) error, then restart that console's drain loop. * This is the "slept past the in-loop recovery budget" case: a standby @@ -2077,13 +2081,23 @@ export const useUploadQueueStore = create((set, get) => { if (!item) return; // A running install: ask the engine to stop it (it stops serving the package; the PS5's // download then fails, and a download already cancelled on the PS5 is let go at once - // instead of after the 5-minute stall bound). The row goes once the job has ended. Before - // the engine has a job there is nothing on the wire yet: fall through to the plain cancel. - if (item.sourceKind === "install" && item.status === "running" && item.installJobId) { + // instead of after the 5-minute stall bound). The row goes once the job has ended. + if (item.sourceKind === "install" && item.status === "running") { if (item.stopping) return; set((s) => ({ items: patchItem(s.items, id, { stopping: true }) })); - void pkgInstallStop(item.installJobId).catch(() => undefined); void (async () => { + // Stopped while the engine was still starting the install: its job id comes in a + // moment, and only that stops it. Dropping the row alone left the install running + // on the engine with nothing showing it. + let job = item.installJobId ?? null; + for (let i = 0; !job && i < 30; i++) { + await new Promise((r) => setTimeout(r, 500)); + const now = get().items.find((it) => it.id === id); + if (!now || now.status !== "running") break; + job = now.installJobId ?? null; + } + if (job) await pkgInstallStop(job).catch(() => undefined); + else get().stopHost(hostOf(item.addr)); for (let i = 0; i < 60; i++) { const now = get().items.find((it) => it.id === id); if (!now || now.status !== "running") break; @@ -2092,6 +2106,9 @@ export const useUploadQueueStore = create((set, get) => { settle(id, { ok: false, message: "Stopped." }); set((s) => ({ items: removeItem(s.items, id) })); scheduleSave(); + // Stopping the loop held the console's other waiting items: let them go on. + const h = hostOf(item.addr); + if (!job && nextPendingForHost(get().items, h)) void get().startHost(h); })(); return; } @@ -2236,6 +2253,19 @@ export const useUploadQueueStore = create((set, get) => { return true; }, + async discardPartial(id) { + const item = get().items.find((candidate) => candidate.id === id); + const path = item ? partialUploadPath(item) : null; + if (!item || !path) return; + await fsDelete(mgmtAddr(hostOf(item.addr)), path); + set((s) => ({ + items: s.items.map((candidate) => + candidate.id === id ? { ...candidate, txIdHex: generateTxIdHex() } : candidate, + ), + })); + scheduleSave(); + }, + async resumeFailedRecoverable(host) { if (!useUploadSettingsStore.getState().autoResume) return 0; const h = hostOf(host); @@ -2447,6 +2477,7 @@ export const useUploadQueueStore = create((set, get) => { retryFailed: () => undefined, retryItem: () => false, retryWithPassword: () => false, + discardPartial: () => Promise.resolve(), resumeFailedRecoverable: () => Promise.resolve(0), setContinueOnFailure: () => undefined, start: () => Promise.resolve(),