Reddit fixes: folder games register with ShadowMount+, resumable stalls, solid-rar retries, PM updates, drag to move

- Receiver writes sce_sys/param.json and param.sfo as .ava-hold and puts them in
  place when the job finishes: ShadowMount+ registered a folder whose eboot.bin
  was still arriving, failed twice and skipped the title for good (repro on
  13.60).
- AVA1 SkipProgress (0x30): a sender reading past data the receiver holds (a
  resumed solid .rar/.7z) keeps the job alive; the console ended it as stalled
  after 900 s on every retry.
- RAR5 unknown-size entries no longer wrap the unpacked total.
- Upload queue: stalled rows offer Restart transfer, stopped/interrupted rows
  offer Resume, "Retry failed uploads" actually runs them and is translated as
  an action.
- Payload Manager tab: Update for payloads stored from the Catalog, keeping the
  boot-list place; repository installs are left to Payload Manager.
- Files: drag folders (and, without drag-out, files) onto a folder or the
  breadcrumb to move them.
- README links the compose file from the feature list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-15 18:05:34 -07:00
1 parent 42403e4892
commit f61f5629e7
51 files changed
+1117 -40

No files matched your search

+26
View File
@@ -6,6 +6,32 @@ What's new in ps5upload, written for humans.
## 7.0.26
- **Uploaded game and homebrew folders show up with ShadowMount+ again.** ShadowMount+
registers a folder as soon as its `sce_sys` has been still for 10 seconds; an upload sends
those small files first, so it tried while `eboot.bin` was still on its way, failed twice and
gave up on the title for good (measured on a 13.60 console). A game's `param.json` (and a PS4
game's `param.sfo`) is now put in place only when everything else has arrived, so the folder
becomes a game exactly when it is complete. Needs the 7.0.26 helper.
- **A stalled or stopped upload can be continued.** A row that has moved no data for 90 seconds
says so and offers **Restart transfer**; a row that was stopped, or interrupted by closing the
app, says so and offers **Resume**. Both pick up from what the PS5 already has. **Retry
failed uploads** now runs them (it only put them back in the list), and its label no longer
reads "the retry failed" in most languages.
- **Large solid .rar uploads no longer fail over and over on a retry.** Resuming one reads
through everything the PS5 already has before sending anything new; the console took that
silence for a stalled sender and ended the job after 15 minutes, every time. The app now
tells the console it is still working. Needs the 7.0.26 helper.
- **A .rar's unpacked size is right** when it holds entries of unknown size (RAR5), which made
the free-space check before an upload add up to nonsense.
- **Payload Manager: update what you stored.** Payloads stored from the Catalog are remembered,
and the Payload Manager tab offers **Update** when the catalogue has a newer release: it
stores the new one in its place and keeps its spot in the boot list. Payloads Payload Manager
installed from its own repository are left to it. (Desktop and Android, where the catalogue
is.)
- **Files: drag to move.** Drag a folder onto another folder, or onto the path above the list,
to move it there (across drives it copies, then removes). Files move the same way in the web
UI and on Android; on the desktop a dragged file still goes out to your computer. Not in the
Windows app, where the window takes every drag for its own file drop; use Move to… there.
- **A deleted Collection folder no longer breaks the Collection (#461).** Saving the folder
list checked that every folder still existed, so once one was gone nothing could be saved,
removing that folder included. Only a folder being added is checked now. A scan skips a
+2 -1
View File
@@ -42,7 +42,8 @@
- **Power.** Rest mode, reboot, shut down, and wake over the network (optionally straight
into your signed-in user).
- **Runs everywhere:** macOS, Windows, Linux, Android, or any browser through the self-hosted
Docker web UI. 21 languages.
Docker web UI ([compose file](engine/compose.yaml), see
[Self-hosted engine and web UI](#self-hosted-engine-and-web-ui)). 21 languages.
## What it doesn't do
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "قائمة الانتظار",
"queue_links_cleared_title": "تمّ مسح الروابط في قائمة الانتظار",
"queue_links_cleared_body": "لم يُحتفظ بـ {n} من روابط التثبيت بعد إعادة التشغيل — لا تُحفظ الروابط على القرص أبدًا. أضفها مجددًا من «تثبيت حزمة».",
"console_tab_queue_count": "{n} في قائمة الانتظار",
queue_retry_failed: "فشلت إعادة المحاولة",
queue_stop: "التوقف عن استخدام الرد",
queue_stop_tooltip: "يوقف الإرسال. ما وصل إلى PS5 يُحتفظ به 7 أيام، ويكمل «بدء» من هناك.",
upload_status_stop_tooltip_v2: "يوقف الإرسال. ما وصل إلى PS5 يُحتفظ به 7 أيام؛ ويكمل «استئناف» من هناك.",
@@ -3915,6 +3914,16 @@ payloads_description_ports: "المنافذ المستخدمة على PS5 وما
"profile.username.addOnConsole": "لإضافة مستخدم، افعل ذلك على PS5: تبديل المستخدم ثم إضافة مستخدم.",
"collection.folder_missing": "غير موجود، تم تخطيه",
"collection.missing_roots": "تخطّى آخر فحص ما لم يجده: {paths}. إن كان المجلد محذوفًا فأزله من المجلدات؛ وإن كان على قرص أو خادم فأعد توصيله وافحص مجددًا.",
queue_retry_failed_v2: "أعد محاولة الرفع الفاشل",
queue_stalled: "لم يتحرك شيء منذ {time}. إعادة تشغيل النقل تكمل مما لدى PS5 بالفعل.",
queue_restart_transfer: "أعد تشغيل النقل",
queue_interrupted: "توقف في المنتصف. الاستئناف يكمل مما لدى PS5 بالفعل.",
queue_resume: "استئناف",
pm_update: "تحديث",
pm_update_available: "{tag} متاح",
pm_update_tooltip: "ينزّل الإصدار الأحدث ويخزّنه في Payload Manager مكان هذا، مع الإبقاء على ترتيبه في قائمة الإقلاع.",
pm_updated: "تم تحديث {name} إلى {tag}",
pm_update_failed: "تعذّر تحديث {name}",
};
export default ar;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "সারি",
"queue_links_cleared_title": "সারিতে থাকা লিংকগুলো মুছে ফেলা হয়েছে",
"queue_links_cleared_body": "রিস্টার্টের পর {n}টি ইনস্টল লিংক রাখা হয়নি — লিংক কখনো ডিস্কে সংরক্ষণ করা হয় না। Install Package থেকে আবার যোগ করুন।",
"console_tab_queue_count": "সারিতে {n}টি",
queue_retry_failed: "পুনরায় চেষ্টা করতে ব্যর্থ হয়েছে",
queue_stop: "থামুন",
queue_stop_tooltip: "পাঠানো বন্ধ করে। যা PS5-এ পৌঁছে গেছে তা 7 দিন রাখা হয়, আর শুরু সেখান থেকেই চালিয়ে যায়।",
upload_status_stop_tooltip_v2: "পাঠানো বন্ধ করে। যা PS5-এ পৌঁছে গেছে তা 7 দিন রাখা হয়; পুনরায় শুরু সেখান থেকেই চালিয়ে যায়।",
@@ -3915,6 +3914,16 @@ payloads_description_ports: "PS5-এ ব্যবহৃত পোর্ট এ
"profile.username.addOnConsole": "নতুন ইউজার যোগ করতে PS5-এই করুন: Switch User, তারপর Add User।",
"collection.folder_missing": "পাওয়া যায়নি, বাদ দেওয়া হয়েছে",
"collection.missing_roots": "শেষ স্ক্যান যা খুঁজে পায়নি তা বাদ দিয়েছে: {paths}। ফোল্ডার মুছে ফেলা হলে Folders থেকে সরান; ড্রাইভ বা সার্ভারে থাকলে আবার সংযোগ করে স্ক্যান করুন।",
queue_retry_failed_v2: "ব্যর্থ আপলোড আবার চেষ্টা করুন",
queue_stalled: "{time} ধরে কিছুই এগোয়নি। ট্রান্সফার আবার শুরু করলে PS5-এ যা আছে সেখান থেকে চলবে।",
queue_restart_transfer: "ট্রান্সফার আবার শুরু করুন",
queue_interrupted: "মাঝপথে থেমেছে। চালিয়ে গেলে PS5-এ যা আছে সেখান থেকে চলবে।",
queue_resume: "চালিয়ে যান",
pm_update: "আপডেট",
pm_update_available: "{tag} পাওয়া যাচ্ছে",
pm_update_tooltip: "নতুন রিলিজ ডাউনলোড করে এটির জায়গায় Payload Manager-এ রাখে, বুট তালিকায় এর জায়গা বজায় রেখে।",
pm_updated: "{name} {tag}-এ আপডেট হয়েছে",
pm_update_failed: "{name} আপডেট করা যায়নি",
};
export default bn;
+10 -1
View File
@@ -410,7 +410,6 @@ queue_title: "Warteschlange",
"queue_links_cleared_title": "Links in der Warteschlange wurden entfernt",
"queue_links_cleared_body": "{n} Installationslink(s) wurden nach dem Neustart nicht behalten – Links werden nie auf der Festplatte gespeichert. Füge sie unter „Paket installieren“ erneut hinzu.",
"console_tab_queue_count": "{n} in der Warteschlange",
queue_retry_failed: "Erneuter Versuch fehlgeschlagen",
queue_stop: "Stopp",
queue_stop_tooltip: "Beendet das Senden. Was die PS5 schon erreicht hat, bleibt 7 Tage erhalten; Start macht dort weiter.",
upload_status_stop_tooltip_v2: "Beendet das Senden. Was die PS5 schon erreicht hat, bleibt 7 Tage erhalten; Fortsetzen macht dort weiter.",
@@ -3923,6 +3922,16 @@ payloads_description_ports: "Die belegten Ports auf der PS5 und was dort üblich
"profile.username.addOnConsole": "Einen Benutzer fügst du auf der PS5 hinzu: Benutzer wechseln, dann Benutzer hinzufügen.",
"collection.folder_missing": "Nicht gefunden, übersprungen",
"collection.missing_roots": "Der letzte Scan hat übersprungen, was er nicht finden konnte: {paths}. Wurde ein Ordner gelöscht, entferne ihn unter Ordner; liegt er auf einem Laufwerk oder Server, verbinde ihn wieder und scanne erneut.",
queue_retry_failed_v2: "Fehlgeschlagene Uploads wiederholen",
queue_stalled: "Seit {time} bewegt sich nichts. Ein Neustart der Übertragung macht dort weiter, wo die PS5 schon Daten hat.",
queue_restart_transfer: "Übertragung neu starten",
queue_interrupted: "Unterwegs angehalten. Fortsetzen macht dort weiter, wo die PS5 schon Daten hat.",
queue_resume: "Fortsetzen",
pm_update: "Aktualisieren",
pm_update_available: "{tag} verfügbar",
pm_update_tooltip: "Lädt die neuere Version herunter und legt sie anstelle dieser in Payload Manager ab; ihr Platz in der Startliste bleibt.",
pm_updated: "{name} auf {tag} aktualisiert",
pm_update_failed: "{name} konnte nicht aktualisiert werden",
};
export default de;
+10 -1
View File
@@ -693,7 +693,6 @@ queue_title: "Queue",
"queue_links_cleared_title": "Queued links were cleared",
"queue_links_cleared_body": "{n} install link(s) weren't kept after restart — links are never saved to disk. Add them again from Install Package.",
"console_tab_queue_count": "{n} in queue",
queue_retry_failed: "Retry failed",
queue_stop: "Stop",
queue_stop_tooltip: "Stops sending. What already reached the PS5 is kept for 7 days, and Start continues from there.",
upload_status_stop_tooltip_v2: "Stops sending. What already reached the PS5 is kept for 7 days; Resume continues from there.",
@@ -4238,6 +4237,16 @@ payloads_description_ports: "The ports in use on the PS5 and what usually listen
"profile.username.addOnConsole": "To add a user, do it on the PS5: Switch User, then Add User.",
"collection.folder_missing": "Not found, skipped",
"collection.missing_roots": "The last scan skipped what it could not find: {paths}. If a folder was deleted, remove it in Folders; if it is on a drive or server, reconnect it and scan again.",
queue_retry_failed_v2: "Retry failed uploads",
queue_stalled: "Nothing has moved for {time}. Restarting the transfer picks up from what the PS5 already has.",
queue_restart_transfer: "Restart transfer",
queue_interrupted: "Stopped part-way. Resuming continues from what the PS5 already has.",
queue_resume: "Resume",
pm_update: "Update",
pm_update_available: "{tag} available",
pm_update_tooltip: "Downloads the newer release and stores it in Payload Manager in place of this one, keeping its place in the boot list.",
pm_updated: "Updated {name} to {tag}",
pm_update_failed: "Couldn't update {name}",
};
export default en;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "Cola",
"queue_links_cleared_title": "Se quitaron los enlaces en cola",
"queue_links_cleared_body": "{n} enlace(s) de instalación no se conservaron tras reiniciar: los enlaces nunca se guardan en disco. Vuelve a añadirlos desde Instalar paquete.",
"console_tab_queue_count": "{n} en cola",
queue_retry_failed: "Reintento fallido",
queue_stop: "Parada",
queue_stop_tooltip: "Deja de enviar. Lo que ya llegó a la PS5 se conserva 7 días y Iniciar continúa desde ahí.",
upload_status_stop_tooltip_v2: "Deja de enviar. Lo que ya llegó a la PS5 se conserva 7 días; Reanudar continúa desde ahí.",
@@ -3865,6 +3864,16 @@ payloads_description_ports: "Los puertos en uso en la PS5 y lo que suele escucha
"profile.username.addOnConsole": "Para añadir un usuario, hazlo en la PS5: Cambiar de usuario y luego Añadir usuario.",
"collection.folder_missing": "No encontrada, omitida",
"collection.missing_roots": "El último análisis omitió lo que no encontró: {paths}. Si una carpeta se borró, quítala en Carpetas; si está en una unidad o servidor, vuelve a conectarla y analiza de nuevo.",
queue_retry_failed_v2: "Reintentar subidas fallidas",
queue_stalled: "No se ha movido nada en {time}. Reiniciar la transferencia continúa desde lo que la PS5 ya tiene.",
queue_restart_transfer: "Reiniciar transferencia",
queue_interrupted: "Se detuvo a medias. Reanudar continúa desde lo que la PS5 ya tiene.",
queue_resume: "Reanudar",
pm_update: "Actualizar",
pm_update_available: "{tag} disponible",
pm_update_tooltip: "Descarga la versión más nueva y la guarda en Payload Manager en lugar de esta, manteniendo su lugar en la lista de arranque.",
pm_updated: "{name} actualizado a {tag}",
pm_update_failed: "No se pudo actualizar {name}",
};
export default es;
+10 -1
View File
@@ -646,7 +646,6 @@ queue_title: "صف",
"queue_links_cleared_title": "لینک‌های در صف پاک شدند",
"queue_links_cleared_body": "{n} لینک نصب پس از ری‌استارت نگه داشته نشد — لینک‌ها هرگز روی دیسک ذخیره نمی‌شوند. آن‌ها را از Install Package دوباره اضافه کنید.",
"console_tab_queue_count": "{n} در صف",
queue_retry_failed: "تلاش دوباره ناموفق بود",
queue_stop: "توقف",
queue_stop_tooltip: "ارسال را متوقف می‌کند. آنچه به PS5 رسیده ۷ روز نگه داشته می‌شود و «شروع» از همان‌جا ادامه می‌دهد.",
upload_status_stop_tooltip_v2: "ارسال را متوقف می‌کند. آنچه به PS5 رسیده ۷ روز نگه داشته می‌شود؛ «ادامه» از همان‌جا ادامه می‌دهد.",
@@ -4216,6 +4215,16 @@ payloads_description_ports: "پورت‌های در حال استفاده روی
"profile.username.addOnConsole": "برای افزودن کاربر، این کار را روی PS5 انجام دهید: تغییر کاربر، سپس افزودن کاربر.",
"collection.folder_missing": "پیدا نشد، رد شد",
"collection.missing_roots": "آخرین اسکن آنچه را پیدا نکرد رد کرد: {paths}. اگر پوشه‌ای حذف شده، آن را از پوشه‌ها بردارید؛ اگر روی درایو یا سرور است، دوباره وصلش کنید و دوباره اسکن کنید.",
queue_retry_failed_v2: "تلاش دوباره برای آپلودهای ناموفق",
queue_stalled: "{time} است که چیزی جابه‌جا نشده. شروع دوبارهٔ انتقال از آنچه PS5 دارد ادامه می‌دهد.",
queue_restart_transfer: "شروع دوبارهٔ انتقال",
queue_interrupted: "در میانه متوقف شد. ادامه از آنچه PS5 دارد پی گرفته می‌شود.",
queue_resume: "ادامه",
pm_update: "به‌روزرسانی",
pm_update_available: "{tag} موجود است",
pm_update_tooltip: "نسخهٔ جدیدتر را دانلود و به‌جای این یکی در Payload Manager ذخیره می‌کند و جایش را در فهرست بوت نگه می‌دارد.",
pm_updated: "{name} به {tag} به‌روزرسانی شد",
pm_update_failed: "به‌روزرسانی {name} ممکن نشد",
};
export default fa;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "File d’attente",
"queue_links_cleared_title": "Les liens en attente ont été retirés",
"queue_links_cleared_body": "{n} lien(s) d’installation n’ont pas été conservés après le redémarrage — les liens ne sont jamais enregistrés sur le disque. Ajoute-les à nouveau depuis Installer un paquet.",
"console_tab_queue_count": "{n} en attente",
queue_retry_failed: "Échec de la nouvelle tentative",
queue_stop: "Arrêter",
queue_stop_tooltip: "Arrête l'envoi. Ce qui est déjà arrivé sur la PS5 est conservé 7 jours, et Démarrer reprend à cet endroit.",
upload_status_stop_tooltip_v2: "Arrête l'envoi. Ce qui est déjà arrivé sur la PS5 est conservé 7 jours ; Reprendre repart de là.",
@@ -3933,6 +3932,16 @@ payloads_description_ports: "Les ports utilisés sur la PS5 et ce qui y écoute
"profile.username.addOnConsole": "Pour ajouter un utilisateur, faites-le sur la PS5 : Changer d'utilisateur, puis Ajouter un utilisateur.",
"collection.folder_missing": "Introuvable, ignoré",
"collection.missing_roots": "La dernière analyse a ignoré ce qu'elle n'a pas trouvé : {paths}. Si un dossier a été supprimé, retirez-le dans Dossiers ; s'il est sur un disque ou un serveur, reconnectez-le et relancez l'analyse.",
queue_retry_failed_v2: "Relancer les envois échoués",
queue_stalled: "Rien n'a bougé depuis {time}. Relancer le transfert reprend à partir de ce que la PS5 a déjà.",
queue_restart_transfer: "Relancer le transfert",
queue_interrupted: "Arrêté en cours de route. Reprendre continue à partir de ce que la PS5 a déjà.",
queue_resume: "Reprendre",
pm_update: "Mettre à jour",
pm_update_available: "{tag} disponible",
pm_update_tooltip: "Télécharge la version plus récente et la stocke dans Payload Manager à la place de celle-ci, en gardant sa place dans la liste de démarrage.",
pm_updated: "{name} mis à jour en {tag}",
pm_update_failed: "Impossible de mettre à jour {name}",
};
export default fr;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "कतार",
"queue_links_cleared_title": "कतार में लगे लिंक हटा दिए गए",
"queue_links_cleared_body": "रीस्टार्ट के बाद {n} इंस्टॉल लिंक नहीं रखे गए — लिंक कभी डिस्क पर सहेजे नहीं जाते। Install Package से इन्हें फिर जोड़ें।",
"console_tab_queue_count": "कतार में {n}",
queue_retry_failed: "फिर से कोशिश नहीं की जा सकी",
queue_stop: "रोकें",
queue_stop_tooltip: "भेजना रोकता है। जो PS5 तक पहुँच चुका है वह 7 दिन रखा जाता है, और शुरू वहीं से जारी रखता है।",
upload_status_stop_tooltip_v2: "भेजना रोकता है। जो PS5 तक पहुँच चुका है वह 7 दिन रखा जाता है; फिर से शुरू वहीं से जारी रखता है।",
@@ -3931,6 +3930,16 @@ payloads_description_ports: "PS5 पर उपयोग में पोर्
"profile.username.addOnConsole": "नया यूज़र जोड़ने के लिए PS5 पर ही करें: Switch User, फिर Add User।",
"collection.folder_missing": "नहीं मिला, छोड़ा गया",
"collection.missing_roots": "पिछले स्कैन ने जो नहीं मिला उसे छोड़ दिया: {paths}। अगर फ़ोल्डर हटाया गया है तो Folders में उसे हटाएँ; अगर वह ड्राइव या सर्वर पर है तो फिर से जोड़ें और दोबारा स्कैन करें।",
queue_retry_failed_v2: "विफल अपलोड फिर से आज़माएँ",
queue_stalled: "{time} से कुछ आगे नहीं बढ़ा। ट्रांसफ़र फिर से शुरू करने पर PS5 में जो है वहीं से जारी रहेगा।",
queue_restart_transfer: "ट्रांसफ़र फिर से शुरू करें",
queue_interrupted: "बीच में रुक गया। जारी रखने पर PS5 में जो है वहीं से आगे बढ़ेगा।",
queue_resume: "जारी रखें",
pm_update: "अपडेट करें",
pm_update_available: "{tag} उपलब्ध",
pm_update_tooltip: "नया रिलीज़ डाउनलोड करके इसकी जगह Payload Manager में रखता है, बूट सूची में इसकी जगह बनाए रखते हुए।",
pm_updated: "{name} को {tag} पर अपडेट किया गया",
pm_update_failed: "{name} अपडेट नहीं हो सका",
};
export default hi;
+10 -1
View File
@@ -648,7 +648,6 @@ queue_title: "Sor",
"queue_links_cleared_title": "A sorban lévő hivatkozások törlődtek",
"queue_links_cleared_body": "{n} telepítési hivatkozás nem maradt meg az újraindítás után – a hivatkozásokat sosem mentjük lemezre. Add hozzá őket újra a Csomag telepítése oldalon.",
"console_tab_queue_count": "{n} a sorban",
queue_retry_failed: "Sikertelenek újrapróbálása",
queue_stop: "Leállítás",
queue_stop_tooltip: "Leállítja a küldést. Ami már a PS5-re ért, 7 napig megmarad, és az Indítás onnan folytatja.",
upload_status_stop_tooltip_v2: "Leállítja a küldést. Ami már a PS5-re ért, 7 napig megmarad; a Folytatás onnan folytatja.",
@@ -4230,6 +4229,16 @@ payloads_description_ports: "A PS5-ön használt portok és hogy mi szokott rajt
"profile.username.addOnConsole": "Felhasználót a PS5-ön adhatsz hozzá: Felhasználóváltás, majd Felhasználó hozzáadása.",
"collection.folder_missing": "Nem található, kihagyva",
"collection.missing_roots": "A legutóbbi keresés kihagyta, amit nem talált: {paths}. Ha egy mappát töröltél, távolítsd el a Mappák között; ha meghajtón vagy szerveren van, csatlakoztasd újra, és keress újra.",
queue_retry_failed_v2: "Sikertelen feltöltések újra",
queue_stalled: "{time} óta semmi sem mozdult. Az átvitel újraindítása onnan folytatja, amit a PS5 már megkapott.",
queue_restart_transfer: "Átvitel újraindítása",
queue_interrupted: "Félúton megállt. A folytatás onnan halad tovább, amit a PS5 már megkapott.",
queue_resume: "Folytatás",
pm_update: "Frissítés",
pm_update_available: "{tag} elérhető",
pm_update_tooltip: "Letölti az újabb kiadást, és ennek helyére teszi a Payload Managerben, megtartva a helyét az indítási listában.",
pm_updated: "{name} frissítve erre: {tag}",
pm_update_failed: "{name} frissítése nem sikerült",
};
export default hu;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "Antrean",
"queue_links_cleared_title": "Tautan dalam antrean dihapus",
"queue_links_cleared_body": "{n} tautan instal tidak disimpan setelah mulai ulang — tautan tidak pernah disimpan ke disk. Tambahkan lagi dari Instal Paket.",
"console_tab_queue_count": "{n} dalam antrean",
queue_retry_failed: "Coba lagi gagal",
queue_stop: "Berhenti",
queue_stop_tooltip: "Berhenti mengirim. Yang sudah sampai di PS5 disimpan 7 hari, dan Mulai melanjutkan dari sana.",
upload_status_stop_tooltip_v2: "Berhenti mengirim. Yang sudah sampai di PS5 disimpan 7 hari; Lanjutkan melanjutkan dari sana.",
@@ -3924,6 +3923,16 @@ payloads_description_ports: "Port yang dipakai di PS5 dan apa yang biasanya mend
"profile.username.addOnConsole": "Untuk menambah pengguna, lakukan di PS5: Ganti Pengguna, lalu Tambah Pengguna.",
"collection.folder_missing": "Tidak ditemukan, dilewati",
"collection.missing_roots": "Pemindaian terakhir melewati yang tidak ditemukannya: {paths}. Jika folder sudah dihapus, hapus dari Folder; jika ada di drive atau server, sambungkan lagi lalu pindai ulang.",
queue_retry_failed_v2: "Coba lagi unggahan yang gagal",
queue_stalled: "Tidak ada yang bergerak selama {time}. Memulai ulang transfer melanjutkan dari yang sudah ada di PS5.",
queue_restart_transfer: "Mulai ulang transfer",
queue_interrupted: "Berhenti di tengah jalan. Melanjutkan akan meneruskan dari yang sudah ada di PS5.",
queue_resume: "Lanjutkan",
pm_update: "Perbarui",
pm_update_available: "{tag} tersedia",
pm_update_tooltip: "Mengunduh rilis yang lebih baru dan menyimpannya di Payload Manager menggantikan yang ini, dengan tetap di urutan boot yang sama.",
pm_updated: "{name} diperbarui ke {tag}",
pm_update_failed: "Tidak bisa memperbarui {name}",
};
export default id;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "Coda",
"queue_links_cleared_title": "I link in coda sono stati rimossi",
"queue_links_cleared_body": "{n} link di installazione non sono stati conservati dopo il riavvio: i link non vengono mai salvati su disco. Aggiungili di nuovo da Installa pacchetto.",
"console_tab_queue_count": "{n} in coda",
queue_retry_failed: "Nuovo tentativo non riuscito",
queue_stop: "Arresta",
queue_stop_tooltip: "Interrompe l'invio. Ciò che è già arrivato sulla PS5 resta per 7 giorni e Avvia riprende da lì.",
upload_status_stop_tooltip_v2: "Interrompe l'invio. Ciò che è già arrivato sulla PS5 resta per 7 giorni; Riprendi continua da lì.",
@@ -3865,6 +3864,16 @@ payloads_description_ports: "Le porte in uso sulla PS5 e cosa di solito vi ascol
"profile.username.addOnConsole": "Per aggiungere un utente, fallo sulla PS5: Cambia utente, poi Aggiungi utente.",
"collection.folder_missing": "Non trovata, saltata",
"collection.missing_roots": "L'ultima scansione ha saltato ciò che non ha trovato: {paths}. Se una cartella è stata eliminata, rimuovila in Cartelle; se è su un'unità o un server, ricollegala e scansiona di nuovo.",
queue_retry_failed_v2: "Riprova i caricamenti non riusciti",
queue_stalled: "Non si muove nulla da {time}. Riavviare il trasferimento riparte da ciò che la PS5 ha già.",
queue_restart_transfer: "Riavvia trasferimento",
queue_interrupted: "Interrotto a metà. Riprendere continua da ciò che la PS5 ha già.",
queue_resume: "Riprendi",
pm_update: "Aggiorna",
pm_update_available: "{tag} disponibile",
pm_update_tooltip: "Scarica la versione più recente e la salva in Payload Manager al posto di questa, mantenendo il suo posto nella lista di avvio.",
pm_updated: "{name} aggiornato a {tag}",
pm_update_failed: "Impossibile aggiornare {name}",
};
export default it;
+10 -1
View File
@@ -410,7 +410,6 @@ queue_title: "キュー",
"queue_links_cleared_title": "キュー内のリンクは消去されました",
"queue_links_cleared_body": "再起動後、{n} 件のインストールリンクは保持されませんでした。リンクはディスクに保存されません。パッケージのインストールから追加し直してください。",
"console_tab_queue_count": "キューに {n} 件",
queue_retry_failed: "再試行に失敗しました",
queue_stop: "停止",
queue_stop_tooltip: "送信を止めます。PS5 に届いた分は 7 日間保持され、開始でそこから続きます。",
upload_status_stop_tooltip_v2: "送信を止めます。PS5 に届いた分は 7 日間保持され、再開でそこから続きます。",
@@ -3936,6 +3935,16 @@ payloads_description_ports: "PS5 で使用中のポートと、それぞれで
"profile.username.addOnConsole": "ユーザーの追加は PS5 本体で行ってください:「ユーザーを切り替える」→「ユーザーを追加」。",
"collection.folder_missing": "見つからないためスキップ",
"collection.missing_roots": "前回のスキャンで見つからなかったものはスキップされました:{paths}。フォルダーを削除した場合は「フォルダー」から外してください。ドライブやサーバー上にある場合は接続し直して、もう一度スキャンしてください。",
queue_retry_failed_v2: "失敗したアップロードを再試行",
queue_stalled: "{time} の間、何も進んでいません。転送をやり直すと、PS5 に届いている分の続きから再開します。",
queue_restart_transfer: "転送をやり直す",
queue_interrupted: "途中で止まりました。再開すると PS5 に届いている分の続きから送ります。",
queue_resume: "再開",
pm_update: "更新",
pm_update_available: "{tag} があります",
pm_update_tooltip: "新しいリリースをダウンロードし、これと置き換えて Payload Manager に保存します。起動リストでの順番はそのままです。",
pm_updated: "{name} を {tag} に更新しました",
pm_update_failed: "{name} を更新できませんでした",
};
export default ja;
+10 -1
View File
@@ -410,7 +410,6 @@ queue_title: "대기열",
"queue_links_cleared_title": "대기열의 링크가 지워졌습니다",
"queue_links_cleared_body": "재시작 후 설치 링크 {n}개가 유지되지 않았습니다. 링크는 디스크에 저장되지 않습니다. 패키지 설치에서 다시 추가하세요.",
"console_tab_queue_count": "대기열에 {n}개",
queue_retry_failed: "재시도 실패",
queue_stop: "정지",
queue_stop_tooltip: "전송을 멈춥니다. 이미 PS5에 도착한 부분은 7일간 보관되며, 시작을 누르면 거기서 이어집니다.",
upload_status_stop_tooltip_v2: "전송을 멈춥니다. 이미 PS5에 도착한 부분은 7일간 보관되며, 다시 시작하면 거기서 이어집니다.",
@@ -3934,6 +3933,16 @@ payloads_description_ports: "PS5에서 사용 중인 포트와 각 포트에서
"profile.username.addOnConsole": "사용자 추가는 PS5에서 하세요: 사용자 전환 → 사용자 추가.",
"collection.folder_missing": "찾을 수 없어 건너뜀",
"collection.missing_roots": "마지막 검사에서 찾지 못한 항목을 건너뛰었습니다: {paths}. 폴더를 삭제했다면 폴더에서 제거하고, 드라이브나 서버에 있다면 다시 연결한 뒤 다시 검사하세요.",
queue_retry_failed_v2: "실패한 업로드 다시 시도",
queue_stalled: "{time} 동안 진행이 없습니다. 전송을 다시 시작하면 PS5에 이미 있는 부분부터 이어서 보냅니다.",
queue_restart_transfer: "전송 다시 시작",
queue_interrupted: "중간에 멈췄습니다. 이어서 하면 PS5에 이미 있는 부분부터 계속합니다.",
queue_resume: "이어서 하기",
pm_update: "업데이트",
pm_update_available: "{tag} 사용 가능",
pm_update_tooltip: "새 릴리스를 내려받아 이것 대신 Payload Manager에 저장하며, 부팅 목록의 순서는 그대로 둡니다.",
pm_updated: "{name}을(를) {tag}(으)로 업데이트했습니다",
pm_update_failed: "{name}을(를) 업데이트하지 못했습니다",
};
export default ko;
+10 -1
View File
@@ -630,7 +630,6 @@ queue_title: "Kolejka",
"queue_links_cleared_title": "Linki w kolejce zostały usunięte",
"queue_links_cleared_body": "{n} link(i) instalacyjnych nie zachowano po ponownym uruchomieniu — linki nigdy nie są zapisywane na dysku. Dodaj je ponownie w Instaluj pakiet.",
"console_tab_queue_count": "{n} w kolejce",
queue_retry_failed: "Ponów nieudane",
queue_stop: "Zatrzymaj",
queue_stop_tooltip: "Zatrzymuje wysyłanie. To, co już dotarło na PS5, jest zachowane przez 7 dni, a Start kontynuuje od tego miejsca.",
upload_status_stop_tooltip_v2: "Zatrzymuje wysyłanie. To, co już dotarło na PS5, jest zachowane przez 7 dni; Wznów kontynuuje od tego miejsca.",
@@ -4170,6 +4169,16 @@ payloads_description_ports: "Porty używane na PS5 i to, co zwykle na nich nasł
"profile.username.addOnConsole": "Użytkownika dodasz na PS5: Przełącz użytkownika, a potem Dodaj użytkownika.",
"collection.folder_missing": "Nie znaleziono, pominięto",
"collection.missing_roots": "Ostatnie skanowanie pominęło to, czego nie znalazło: {paths}. Jeśli folder został usunięty, usuń go w Foldery; jeśli jest na dysku lub serwerze, podłącz go ponownie i skanuj jeszcze raz.",
queue_retry_failed_v2: "Ponów nieudane wysyłki",
queue_stalled: "Od {time} nic się nie rusza. Ponowne uruchomienie transferu wznowi od tego, co PS5 już ma.",
queue_restart_transfer: "Uruchom transfer ponownie",
queue_interrupted: "Zatrzymano w połowie. Wznowienie kontynuuje od tego, co PS5 już ma.",
queue_resume: "Wznów",
pm_update: "Aktualizuj",
pm_update_available: "Dostępna {tag}",
pm_update_tooltip: "Pobiera nowsze wydanie i zapisuje je w Payload Manager zamiast tego, zachowując jego miejsce na liście startowej.",
pm_updated: "Zaktualizowano {name} do {tag}",
pm_update_failed: "Nie udało się zaktualizować {name}",
};
export default pl;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "Fila",
"queue_links_cleared_title": "Os links da fila foram removidos",
"queue_links_cleared_body": "{n} link(s) de instalação não foram mantidos após reiniciar — links nunca são salvos em disco. Adicione-os de novo em Instalar pacote.",
"console_tab_queue_count": "{n} na fila",
queue_retry_failed: "Tentar novamente",
queue_stop: "Parar",
queue_stop_tooltip: "Para de enviar. O que já chegou ao PS5 fica guardado por 7 dias, e Iniciar continua de lá.",
upload_status_stop_tooltip_v2: "Para de enviar. O que já chegou ao PS5 fica guardado por 7 dias; Retomar continua de lá.",
@@ -3932,6 +3931,16 @@ payloads_description_ports: "As portas em uso no PS5 e o que costuma escutar em
"profile.username.addOnConsole": "Para adicionar um usuário, faça no PS5: Trocar de usuário e depois Adicionar usuário.",
"collection.folder_missing": "Não encontrada, ignorada",
"collection.missing_roots": "A última verificação ignorou o que não encontrou: {paths}. Se uma pasta foi apagada, remova-a em Pastas; se está em um drive ou servidor, reconecte e verifique de novo.",
queue_retry_failed_v2: "Tentar de novo os envios que falharam",
queue_stalled: "Nada se moveu em {time}. Reiniciar a transferência continua do que o PS5 já tem.",
queue_restart_transfer: "Reiniciar transferência",
queue_interrupted: "Parou no meio. Retomar continua do que o PS5 já tem.",
queue_resume: "Retomar",
pm_update: "Atualizar",
pm_update_available: "{tag} disponível",
pm_update_tooltip: "Baixa a versão mais nova e a guarda no Payload Manager no lugar desta, mantendo a posição na lista de inicialização.",
pm_updated: "{name} atualizado para {tag}",
pm_update_failed: "Não foi possível atualizar {name}",
};
export default pt_BR;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "Очередь",
"queue_links_cleared_title": "Ссылки в очереди удалены",
"queue_links_cleared_body": "После перезапуска {n} ссылок на установку не сохранено — ссылки никогда не записываются на диск. Добавь их снова в «Установить пакет».",
"console_tab_queue_count": "В очереди: {n}",
queue_retry_failed: "Повторить попытку не удалось",
queue_stop: "Остановить",
queue_stop_tooltip: "Останавливает отправку. Что уже дошло до PS5, хранится 7 дней, и «Старт» продолжит с этого места.",
upload_status_stop_tooltip_v2: "Останавливает отправку. Что уже дошло до PS5, хранится 7 дней; «Продолжить» возобновит с этого места.",
@@ -3865,6 +3864,16 @@ payloads_description_ports: "Занятые порты на PS5 и что обы
"profile.username.addOnConsole": "Пользователя добавляют на самой PS5: «Сменить пользователя», затем «Добавить пользователя».",
"collection.folder_missing": "Не найдена, пропущена",
"collection.missing_roots": "Последнее сканирование пропустило то, что не нашло: {paths}. Если папка удалена, уберите её в разделе «Папки»; если она на диске или сервере, подключите его снова и повторите сканирование.",
queue_retry_failed_v2: "Повторить неудавшиеся загрузки",
queue_stalled: "Уже {time} ничего не передаётся. Перезапуск передачи продолжит с того, что уже есть на PS5.",
queue_restart_transfer: "Перезапустить передачу",
queue_interrupted: "Остановлено на полпути. Возобновление продолжит с того, что уже есть на PS5.",
queue_resume: "Возобновить",
pm_update: "Обновить",
pm_update_available: "Доступна {tag}",
pm_update_tooltip: "Скачивает более новый выпуск и сохраняет его в Payload Manager вместо этого, сохраняя место в списке загрузки.",
pm_updated: "{name} обновлён до {tag}",
pm_update_failed: "Не удалось обновить {name}",
};
export default ru;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "คิว",
"queue_links_cleared_title": "ลิงก์ในคิวถูกล้างแล้ว",
"queue_links_cleared_body": "ไม่ได้เก็บลิงก์ติดตั้ง {n} รายการหลังรีสตาร์ต — ลิงก์จะไม่ถูกบันทึกลงดิสก์ เพิ่มอีกครั้งได้จาก ติดตั้งแพ็กเกจ",
"console_tab_queue_count": "{n} รายการในคิว",
queue_retry_failed: "การลองใหม่ล้มเหลว",
queue_stop: "หยุด",
queue_stop_tooltip: "หยุดส่ง สิ่งที่ถึง PS5 แล้วจะเก็บไว้ 7 วัน และ เริ่ม จะทำต่อจากตรงนั้น",
upload_status_stop_tooltip_v2: "หยุดส่ง สิ่งที่ถึง PS5 แล้วจะเก็บไว้ 7 วัน; ดำเนินการต่อ จะทำต่อจากตรงนั้น",
@@ -3924,6 +3923,16 @@ payloads_description_ports: "พอร์ตที่ใช้งานอยู
"profile.username.addOnConsole": "หากต้องการเพิ่มผู้ใช้ ให้ทำบน PS5: สลับผู้ใช้ แล้วเลือกเพิ่มผู้ใช้",
"collection.folder_missing": "ไม่พบ ข้ามไปแล้ว",
"collection.missing_roots": "การสแกนครั้งล่าสุดข้ามสิ่งที่หาไม่พบ: {paths} หากลบโฟลเดอร์ไปแล้ว ให้นำออกในโฟลเดอร์ หากอยู่บนไดรฟ์หรือเซิร์ฟเวอร์ ให้เชื่อมต่ออีกครั้งแล้วสแกนใหม่",
queue_retry_failed_v2: "ลองอัปโหลดที่ล้มเหลวอีกครั้ง",
queue_stalled: "ไม่มีความคืบหน้ามา {time} แล้ว การเริ่มส่งใหม่จะต่อจากส่วนที่ PS5 มีอยู่แล้ว",
queue_restart_transfer: "เริ่มส่งใหม่",
queue_interrupted: "หยุดกลางคัน การส่งต่อจะเริ่มจากส่วนที่ PS5 มีอยู่แล้ว",
queue_resume: "ส่งต่อ",
pm_update: "อัปเดต",
pm_update_available: "มี {tag} แล้ว",
pm_update_tooltip: "ดาวน์โหลดรุ่นที่ใหม่กว่าและเก็บใน Payload Manager แทนตัวนี้ โดยคงลำดับในรายการบูตไว้",
pm_updated: "อัปเดต {name} เป็น {tag} แล้ว",
pm_update_failed: "อัปเดต {name} ไม่ได้",
};
export default th;
+10 -1
View File
@@ -409,7 +409,6 @@ queue_title: "Kuyruk",
"queue_links_cleared_title": "Sıradaki bağlantılar temizlendi",
"queue_links_cleared_body": "Yeniden başlatmadan sonra {n} kurulum bağlantısı korunmadı — bağlantılar asla diske kaydedilmez. Paket Kur'dan yeniden ekle.",
"console_tab_queue_count": "Sırada {n}",
queue_retry_failed: "Yeniden deneme başarısız oldu",
queue_stop: "Durdurma",
queue_stop_tooltip: "Göndermeyi durdurur. PS5'e ulaşanlar 7 gün saklanır ve Başlat oradan devam eder.",
upload_status_stop_tooltip_v2: "Göndermeyi durdurur. PS5'e ulaşanlar 7 gün saklanır; Sürdür oradan devam eder.",
@@ -3922,6 +3921,16 @@ payloads_description_ports: "PS5'te kullanılan portlar ve her birinde genellikl
"profile.username.addOnConsole": "Kullanıcı eklemek için bunu PS5'te yap: Kullanıcı Değiştir, ardından Kullanıcı Ekle.",
"collection.folder_missing": "Bulunamadı, atlandı",
"collection.missing_roots": "Son tarama bulamadıklarını atladı: {paths}. Bir klasör silindiyse Klasörler'den kaldır; bir sürücüde veya sunucudaysa yeniden bağla ve tekrar tara.",
queue_retry_failed_v2: "Başarısız yüklemeleri yeniden dene",
queue_stalled: "{time} boyunca hiçbir şey ilerlemedi. Aktarımı yeniden başlatmak PS5'te zaten olandan devam eder.",
queue_restart_transfer: "Aktarımı yeniden başlat",
queue_interrupted: "Yarıda durdu. Devam etmek PS5'te zaten olandan sürdürür.",
queue_resume: "Devam et",
pm_update: "Güncelle",
pm_update_available: "{tag} mevcut",
pm_update_tooltip: "Daha yeni sürümü indirir ve bunun yerine Payload Manager'a kaydeder; açılış listesindeki yeri korunur.",
pm_updated: "{name}, {tag} sürümüne güncellendi",
pm_update_failed: "{name} güncellenemedi",
};
export default tr;
+10 -1
View File
@@ -410,7 +410,6 @@ queue_title: "Hàng đợi",
"queue_links_cleared_title": "Các liên kết trong hàng đợi đã bị xóa",
"queue_links_cleared_body": "{n} liên kết cài đặt không được giữ sau khi khởi động lại — liên kết không bao giờ được lưu vào ổ đĩa. Hãy thêm lại từ Cài đặt gói.",
"console_tab_queue_count": "{n} trong hàng đợi",
queue_retry_failed: "Thử lại không thành công.",
queue_stop: "Dừng lại",
queue_stop_tooltip: "Dừng gửi. Những gì đã đến PS5 được giữ 7 ngày, và Bắt đầu sẽ tiếp tục từ đó.",
upload_status_stop_tooltip_v2: "Dừng gửi. Những gì đã đến PS5 được giữ 7 ngày; Tiếp tục sẽ tiếp từ đó.",
@@ -3917,6 +3916,16 @@ payloads_description_ports: "Các cổng đang dùng trên PS5 và thứ thườ
"profile.username.addOnConsole": "Để thêm người dùng, hãy làm trên PS5: Chuyển người dùng, rồi Thêm người dùng.",
"collection.folder_missing": "Không tìm thấy, đã bỏ qua",
"collection.missing_roots": "Lần quét gần nhất đã bỏ qua những gì không tìm thấy: {paths}. Nếu thư mục đã bị xóa, hãy gỡ nó trong Thư mục; nếu nằm trên ổ đĩa hoặc máy chủ, hãy kết nối lại rồi quét lại.",
queue_retry_failed_v2: "Thử lại các lần tải lên thất bại",
queue_stalled: "Không có gì di chuyển trong {time}. Khởi động lại việc truyền sẽ tiếp tục từ phần PS5 đã có.",
queue_restart_transfer: "Khởi động lại việc truyền",
queue_interrupted: "Đã dừng giữa chừng. Tiếp tục sẽ nối tiếp từ phần PS5 đã có.",
queue_resume: "Tiếp tục",
pm_update: "Cập nhật",
pm_update_available: "Có {tag}",
pm_update_tooltip: "Tải bản phát hành mới hơn và lưu vào Payload Manager thay cho bản này, giữ nguyên vị trí trong danh sách khởi động.",
pm_updated: "Đã cập nhật {name} lên {tag}",
pm_update_failed: "Không cập nhật được {name}",
};
export default vi;
+10 -1
View File
@@ -416,7 +416,6 @@ queue_title: "队列",
"queue_links_cleared_title": "队列中的链接已清除",
"queue_links_cleared_body": "重启后未保留 {n} 个安装链接——链接从不保存到磁盘。请在“安装包”中重新添加。",
"console_tab_queue_count": "队列中 {n} 项",
queue_retry_failed: "重试失败",
queue_stop: "停止",
queue_stop_tooltip: "停止发送。已到达 PS5 的部分保留 7 天,“开始”会从那里继续。",
upload_status_stop_tooltip_v2: "停止发送。已到达 PS5 的部分保留 7 天;“继续”会从那里继续。",
@@ -3871,6 +3870,16 @@ payloads_description_ports: "PS5 上正在使用的端口以及通常在每个
"profile.username.addOnConsole": "要添加用户,请在 PS5 上操作:切换用户,然后选择添加用户。",
"collection.folder_missing": "未找到,已跳过",
"collection.missing_roots": "上次扫描跳过了找不到的内容:{paths}。如果文件夹已删除,请在“文件夹”中移除;如果它在硬盘或服务器上,请重新连接后再扫描。",
queue_retry_failed_v2: "重试失败的上传",
queue_stalled: "已经 {time} 没有任何进展。重新开始传输会从 PS5 已有的部分继续。",
queue_restart_transfer: "重新开始传输",
queue_interrupted: "中途停止了。继续会从 PS5 已有的部分接着传。",
queue_resume: "继续",
pm_update: "更新",
pm_update_available: "有 {tag}",
pm_update_tooltip: "下载较新的版本并替换此项存入 Payload Manager,开机列表中的位置不变。",
pm_updated: "已将 {name} 更新到 {tag}",
pm_update_failed: "无法更新 {name}",
};
export default zh_CN;
+10 -1
View File
@@ -410,7 +410,6 @@ queue_title: "佇列",
"queue_links_cleared_title": "佇列中的連結已清除",
"queue_links_cleared_body": "重新啟動後未保留 {n} 個安裝連結——連結從不儲存到磁碟。請在「安裝檔」中重新加入。",
"console_tab_queue_count": "佇列中 {n} 項",
queue_retry_failed: "重試失敗",
queue_stop: "停止",
queue_stop_tooltip: "停止傳送。已到達 PS5 的部分保留 7 天,「開始」會從那裡繼續。",
upload_status_stop_tooltip_v2: "停止傳送。已到達 PS5 的部分保留 7 天;「繼續」會從那裡繼續。",
@@ -3865,6 +3864,16 @@ payloads_description_ports: "PS5 上正在使用的連接埠,以及通常在
"profile.username.addOnConsole": "要新增使用者,請在 PS5 上操作:切換使用者,然後選擇新增使用者。",
"collection.folder_missing": "找不到,已略過",
"collection.missing_roots": "上次掃描略過了找不到的內容:{paths}。如果資料夾已刪除,請在「資料夾」中移除;如果它在硬碟或伺服器上,請重新連線後再掃描。",
queue_retry_failed_v2: "重試失敗的上傳",
queue_stalled: "已經 {time} 沒有任何進展。重新開始傳輸會從 PS5 已有的部分繼續。",
queue_restart_transfer: "重新開始傳輸",
queue_interrupted: "中途停止了。繼續會從 PS5 已有的部分接著傳。",
queue_resume: "繼續",
pm_update: "更新",
pm_update_available: "有 {tag}",
pm_update_tooltip: "下載較新的版本並取代此項存入 Payload Manager,開機清單中的位置不變。",
pm_updated: "已將 {name} 更新到 {tag}",
pm_update_failed: "無法更新 {name}",
};
export default zh_TW;
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { PayloadReleaseInfo } from "../api/ps5";
import { isRepoManaged, pmUpdateFor, swapAutoloadKey } from "./pmUpdates";
const rel = (tag: string, url = "https://x/a.elf"): PayloadReleaseInfo => ({
payload_id: "nanodns",
tag,
name: tag,
body: "",
published_at: "",
html_url: "",
picked_asset_url: url,
picked_asset_name: "a.elf",
picked_asset_size: 1,
cached_age_secs: 0,
});
describe("pmUpdateFor", () => {
it("offers the newer release only for something this app stored", () => {
expect(pmUpdateFor({ id: "nanodns", tag: "v0.4" }, rel("v0.5"))?.tag).toBe("v0.5");
expect(pmUpdateFor({ id: "nanodns", tag: "v0.5" }, rel("v0.5"))).toBeNull();
expect(pmUpdateFor(undefined, rel("v0.5"))).toBeNull();
expect(pmUpdateFor({ id: "nanodns", tag: "v0.4" }, rel("v0.5", ""))).toBeNull();
});
});
describe("swapAutoloadKey", () => {
it("keeps the boot position and the pauses", () => {
expect(swapAutoloadKey(["a.elf", "!3000", "old.elf", "!3000", "c.elf"], "old.elf", "new.elf")).toEqual([
"a.elf",
"!3000",
"new.elf",
"!3000",
"c.elf",
]);
});
it("does not list the new one twice", () => {
expect(swapAutoloadKey(["new.elf", "old.elf"], "old.elf", "new.elf")).toEqual(["new.elf"]);
});
it("leaves a list without the old one alone", () => {
const l = ["a.elf"];
expect(swapAutoloadKey(l, "old.elf", "new.elf")).toBe(l);
});
});
describe("isRepoManaged", () => {
it("knows Payload Manager's own repository installs", () => {
expect(isRepoManaged({ slot: "", path: "/data/pldmgr/payloads/repo/nanodns@e8cce5d1/nanoDNS_0.4.elf" })).toBe(true);
expect(isRepoManaged({ slot: "repo/nanodns@x", path: "/data/pldmgr/payloads/repo/x" })).toBe(true);
expect(isRepoManaged({ slot: "local/nanodns", path: "/data/pldmgr/payloads/local/nanodns/n.elf" })).toBe(false);
});
});
+79
View File
@@ -0,0 +1,79 @@
// Keeping what ps5upload stored in Payload Manager up to date (Reddit/Discord ask: "sync with
// the payloads on the device"). Payload Manager 0.5 reports no versions and many assets keep one
// file name across releases, so the app remembers, per console, which catalogue payload and
// release it stored under each file name. The Payload Manager tab compares that with the
// catalogue's current release and offers the update. Payloads Payload Manager installed from
// its own repository are its to update and are never touched.
import type { PayloadReleaseInfo } from "../api/ps5";
export interface PmStoreRecord {
/** Catalogue payload id. */
id: string;
/** The release tag that was stored. */
tag: string;
}
type Records = Record<string, Record<string, PmStoreRecord>>;
const KEY = "ps5upload.pmStored.v1";
function load(): Records {
try {
const v = JSON.parse(globalThis.localStorage?.getItem(KEY) ?? "{}");
return v && typeof v === "object" ? (v as Records) : {};
} catch {
return {};
}
}
function save(r: Records): void {
try {
globalThis.localStorage?.setItem(KEY, JSON.stringify(r));
} catch {
/* private window or blocked storage: updates are simply not offered */
}
}
/** What this app stored in `host`'s Payload Manager, by file name. */
export function pmRecords(host: string): Record<string, PmStoreRecord> {
return load()[host] ?? {};
}
/** Remember that `filename` on `host` is catalogue payload `id` at release `tag`. */
export function recordPmStore(host: string, filename: string, id: string, tag: string): void {
const all = load();
all[host] = { ...(all[host] ?? {}), [filename]: { id, tag } };
save(all);
}
export function forgetPmStore(host: string, filename: string): void {
const all = load();
if (!all[host]?.[filename]) return;
const { [filename]: _gone, ...rest } = all[host];
void _gone;
all[host] = rest;
save(all);
}
/** Stored from Payload Manager's own repository: it updates those itself. */
export function isRepoManaged(p: { path: string; slot: string }): boolean {
return p.slot.startsWith("repo/") || p.path.includes("/payloads/repo/");
}
/** The release to update to, or null when the stored one is current (or unknown). */
export function pmUpdateFor(
rec: PmStoreRecord | undefined,
latest: PayloadReleaseInfo | null | undefined,
): PayloadReleaseInfo | null {
if (!rec || !latest || !latest.picked_asset_url) return null;
return latest.tag !== rec.tag ? latest : null;
}
/** `list` with `oldKey` replaced by `newKey` in the same place (pauses kept). When `newKey` is
* already listed elsewhere, `oldKey` just goes. */
export function swapAutoloadKey(list: string[], oldKey: string, newKey: string): string[] {
if (oldKey === newKey || !list.includes(oldKey)) return list;
if (list.includes(newKey)) return list.filter((e) => e !== oldKey);
return list.map((e) => (e === oldKey ? newKey : e));
}
+5
View File
@@ -96,6 +96,11 @@ describe("resetRunningToPending", () => {
expect(next[1].bytesPerSec).toBe(0);
});
it("marks a stopped running item interrupted, so it offers Resume", () => {
const next = resetRunningToPending([runItem("b", "running", 600, 1024, 1)]);
expect((next[0] as RunItem & { interrupted?: boolean }).interrupted).toBe(true);
});
it("leaves done items untouched (counters preserved)", () => {
const arr: RunItem[] = [runItem("a", "done", 1024, 1024, 0)];
const next = resetRunningToPending(arr);
+4
View File
@@ -144,6 +144,10 @@ export function resetRunningToPending<
// persisted as a pending row with `recovering:true`.
recovering: false,
recoverAttempt: 0,
// It had started: the row says so and offers to resume, instead of
// looking like an item that never ran.
interrupted: true,
stalledForMs: null,
};
}
return it;
+65 -3
View File
@@ -68,6 +68,7 @@ import {
} from "./fsBrowse";
import { save as saveDialog } from "@tauri-apps/plugin-dialog";
import { isTauriEnv } from "../../lib/tauriEnv";
import { isWindows } from "../../lib/platform";
import { isInstallPackagePath } from "../../lib/pkgDropDedupe";
import {
PageHeader,
@@ -322,6 +323,9 @@ export function isSinglePathName(name: string): boolean {
);
}
/** The in-app drag of rows to move them (the row names, as JSON). */
const FS_DRAG_MIME = "application/x-ps5upload-fs-move";
function crumbs(p: string): { label: string; path: string }[] {
const parts = p.split("/").filter(Boolean);
const out: { label: string; path: string }[] = [{ label: "/", path: "/" }];
@@ -1524,6 +1528,51 @@ export default function FileSystemScreen() {
sourceLabel: path,
});
};
// Drag rows onto a folder (or a breadcrumb) to move them there, like the console's own web
// file manager (Reddit ask). The move is the same cut-and-paste as Move to…, so a drop on
// another drive copies and then removes. On the desktop a dragged FILE still goes out to this
// computer (that drag is native), so files move this way where there is no drag-out.
const [dropTarget, setDropTarget] = useState<string | null>(null);
// The Windows app's window takes every drag for its own file drop (uploads), so no in-page
// drag reaches a row there: offer none rather than one that does nothing.
const dragMoveWorks = !(isTauriEnv() && isWindows());
const movesByDrag = (e: DirEntry) => dragMoveWorks && (e.kind === "dir" || !canDragOut);
const dropProps = (dest: string) => ({
onDragOver: (ev: React.DragEvent) => {
if (!ev.dataTransfer.types.includes(FS_DRAG_MIME)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
if (dropTarget !== dest) setDropTarget(dest);
},
onDragLeave: () => setDropTarget((cur) => (cur === dest ? null : cur)),
onDrop: (ev: React.DragEvent) => {
const raw = ev.dataTransfer.getData(FS_DRAG_MIME);
setDropTarget(null);
if (!raw) return;
ev.preventDefault();
let names: string[];
try {
names = JSON.parse(raw) as string[];
} catch {
return;
}
const byName = new Map((entries ?? []).map((x) => [x.name, x]));
const items = names
.map((n) => byName.get(n))
.filter((x): x is DirEntry => !!x)
.map((x) => ({
path: joinPath(path, x.name),
name: x.name,
size: x.size,
kind: x.kind === "dir" ? ("dir" as const) : ("file" as const),
}))
// Never into itself or below itself, and never where it already is.
.filter((i) => dest !== i.path && !dest.startsWith(`${i.path}/`) && dest !== path);
if (items.length === 0) return;
setSelected(new Set());
void runPaste(dest, { op: "cut", items, sourceLabel: path });
},
});
// Set permissions to 777 (folders recursively): what games and payload folders need to be
// readable and runnable, and what users reached for FileZilla to do (#411).
const [chmodBusy, setChmodBusy] = useState(false);
@@ -2568,7 +2617,9 @@ export default function FileSystemScreen() {
<button
type="button"
onClick={() => setPath(c.path)}
{...(i < arr.length - 1 ? dropProps(c.path) : {})}
className={
(dropTarget === c.path ? "ring-2 ring-[var(--color-accent)] " : "") +
"rounded px-1.5 py-0.5 font-mono " +
(i === arr.length - 1
? "font-medium text-[var(--color-text)]"
@@ -3024,10 +3075,18 @@ export default function FileSystemScreen() {
return (
<li
key={e.name}
// Drag a file out of the window onto this computer (desktop app only).
draggable={!isDir && canDragOut}
// Drag a file out of the window onto this computer (desktop app only); a
// folder (or, without drag-out, a file) drags onto another folder to move.
draggable={movesByDrag(e) || canDragOut}
{...(isDir ? dropProps(joinPath(path, e.name)) : {})}
onDragStart={
!isDir && canDragOut
movesByDrag(e)
? (ev) => {
const names = isSelected ? [...selected] : [e.name];
ev.dataTransfer.setData(FS_DRAG_MIME, JSON.stringify(names));
ev.dataTransfer.effectAllowed = "move";
}
: !isDir && canDragOut
? (ev) => {
ev.preventDefault();
dragOut(
@@ -3054,6 +3113,9 @@ export default function FileSystemScreen() {
}}
className={
"list-row-contain-sm flex items-center gap-3 rounded-[var(--radius-card)] border px-3.5 py-2.5 text-sm transition-colors " +
(isDir && dropTarget === joinPath(path, e.name)
? "ring-2 ring-[var(--color-accent)] "
: "") +
(isSelected
? "border-[color-mix(in_srgb,var(--color-accent)_40%,transparent)] bg-[var(--color-accent-soft)]"
: "border-[var(--glass-edge)] bg-[var(--color-surface-raised)] hover:bg-[var(--color-float)]")
@@ -37,6 +37,8 @@ import {
import { Button, ErrorCard, Spinner, Badge, Input } from "../../components";
import { useConfirm } from "../../components/ConfirmDialog";
import { pmOverview, pmStore } from "../../api/payloadManager";
import { recordPmStore } from "../../lib/pmUpdates";
import { hostOf } from "../../lib/addr";
import { useTr } from "../../state/lang";
import UsbAutoloaderModal from "./UsbAutoloaderModal";
@@ -330,6 +332,8 @@ export default function CatalogPanel() {
r = await pmStore(h, filename, local.path, true);
if (!r.stored) return;
}
// So the Payload Manager tab can offer this one's updates later.
recordPmStore(hostOf(h), filename, id, local.version);
pushNotification(
"success",
withConsolePrefix(h, tr("payloads_pm_stored_title", { name: filename }, `Stored ${filename} in Payload Manager`)),
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Play, RefreshCw, Trash2 } from "lucide-react";
import { ArrowDown, ArrowUp, Download, Play, RefreshCw, Trash2 } from "lucide-react";
import { Badge, Button, Callout, ErrorCard, Spinner, Toggle } from "../../components";
import { useConfirm } from "../../components/ConfirmDialog";
@@ -9,9 +9,22 @@ import {
pmLaunch,
pmOverview,
pmSetAutoload,
pmStore,
type PmOverview,
type PmStoredPayload,
} from "../../api/payloadManager";
import { payloadsDownload, payloadsReleases, type PayloadReleaseInfo } from "../../api/ps5";
import { hostOf } from "../../lib/addr";
import { defaultRelease } from "../../lib/payloadVersions";
import {
forgetPmStore,
isRepoManaged,
pmRecords,
pmUpdateFor,
recordPmStore,
swapAutoloadKey,
} from "../../lib/pmUpdates";
import { isTauriEnv } from "../../lib/tauriEnv";
import { useConnectionStore } from "../../state/connection";
import { useTr } from "../../state/lang";
import { pushNotification } from "../../state/notifications";
@@ -41,6 +54,9 @@ export default function PayloadManagerPanel() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
/** The catalogue's current release for each payload this app stored here (native builds:
* the catalogue talks to GitHub from the app). */
const [latest, setLatest] = useState<Record<string, PayloadReleaseInfo | null>>({});
const refresh = useCallback(async () => {
const h = host.trim();
@@ -61,6 +77,21 @@ export default function PayloadManagerPanel() {
void refresh();
}, [refresh]);
useEffect(() => {
if (!isTauriEnv() || !view?.present) return;
const ids = new Set(Object.values(pmRecords(hostOf(host))).map((r) => r.id));
let live = true;
for (const id of ids) {
if (id in latest) continue;
payloadsReleases(id)
.then((list) => live && setLatest((m) => ({ ...m, [id]: defaultRelease(list) })))
.catch(() => live && setLatest((m) => ({ ...m, [id]: null })));
}
return () => {
live = false;
};
}, [view, host, latest]);
const run = async (key: string, work: () => Promise<void>, failTitle: string) => {
setBusy(key);
try {
@@ -108,6 +139,37 @@ export default function PayloadManagerPanel() {
);
};
/** Downloads the newer release, stores it in Payload Manager (which replaces the old file in
* its folder), keeps its place in the boot list, and drops an old copy left under another name. */
const update = (p: PmStoredPayload, id: string, to: PayloadReleaseInfo) =>
run(
`update:${p.path}`,
async () => {
const h = hostOf(host);
const local = await payloadsDownload(id, to.picked_asset_url, to.tag);
const name = local.path.split(/[\\/]/).pop() || p.filename;
const r = await pmStore(h, name, local.path, true);
if (!r.stored) throw new Error("Payload Manager did not store it");
const after = await pmOverview(h);
if (after.present) {
const fresh = after.payloads.find((x) => x.filename === name && x.path !== p.path) ??
after.payloads.find((x) => x.filename === name);
const oldKey = autoloadKey(p);
if (fresh && after.autoload?.list.includes(oldKey)) {
await pmSetAutoload(h, after.autoload.enabled, swapAutoloadKey(after.autoload.list, oldKey, autoloadKey(fresh)));
}
if (name !== p.filename && after.payloads.some((x) => x.path === p.path)) await pmDelete(h, p.filename);
}
if (name !== p.filename) forgetPmStore(h, p.filename);
recordPmStore(h, name, id, to.tag);
pushNotification(
"success",
withConsolePrefix(host, tr("pm_updated", { name, tag: to.tag }, `Updated ${name} to ${to.tag}`)),
);
},
tr("pm_update_failed", { name: p.filename }, `Couldn't update ${p.filename}`),
);
const setAutoload = (enabled: boolean, list: string[]) =>
run(
"autoload",
@@ -154,7 +216,10 @@ export default function PayloadManagerPanel() {
const stored = view.payloads.filter((p) => p.path.startsWith("/data/pldmgr/"));
const onUsb = view.payloads.filter((p) => !p.path.startsWith("/data/pldmgr/"));
const records = pmRecords(hostOf(host));
const row = (p: PmStoredPayload) => {
const rec = isRepoManaged(p) ? undefined : records[p.filename];
const to = rec ? pmUpdateFor(rec, latest[rec.id]) : null;
const listed = inList(p);
const key = autoloadKey(p);
const order = autoload ? autoload.list.filter((e) => !e.startsWith("!")) : [];
@@ -172,6 +237,11 @@ export default function PayloadManagerPanel() {
{p.version}
</Badge>
)}
{to && (
<Badge tone="warn" variant="outline">
{tr("pm_update_available", { tag: to.tag }, `${to.tag} available`)}
</Badge>
)}
{listed && autoload && (
<Badge tone="good" variant="outline">
{tr("pm_autoload_badge", { n: pos + 1 }, `boot #${pos + 1}`)}
@@ -234,6 +304,22 @@ export default function PayloadManagerPanel() {
)}
</>
)}
{to && rec && (
<Button
variant="secondary"
size="sm"
leftIcon={busy === `update:${p.path}` ? <Spinner size={12} tone="inherit" /> : <Download size={12} />}
disabled={busy !== null}
onClick={() => void update(p, rec.id, to)}
title={tr(
"pm_update_tooltip",
undefined,
"Downloads the newer release and stores it in Payload Manager in place of this one, keeping its place in the boot list.",
)}
>
{tr("pm_update", undefined, "Update")}
</Button>
)}
<Button
variant="primary"
size="sm"
+43 -1
View File
@@ -287,7 +287,7 @@ export const QueuePanel = memo(function QueuePanel({ host }: { host?: string } =
leftIcon={<RotateCcw size={12} />}
onClick={retryFailed}
>
{tr("queue_retry_failed", undefined, "Retry failed")}
{tr("queue_retry_failed_v2", undefined, "Retry failed uploads")}
</Button>
)}
@@ -1014,6 +1014,48 @@ export function QueueRow({
</div>
)}
{isActive && !isRecovering && !isInstall && item.stalledForMs != null && (
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-[var(--radius-field)] bg-[var(--color-warn)]/10 px-2 py-1.5 text-xs text-[var(--color-warn)]">
<span className="min-w-0 flex-1">
{tr(
"queue_stalled",
{ time: formatDuration(item.stalledForMs / 1000) },
"Nothing has moved for {time}. Restarting the transfer picks up from what the PS5 already has.",
)}
</span>
<Button
size="sm"
variant="secondary"
onClick={() => useUploadQueueStore.getState().restartItem(item.id)}
>
{tr("queue_restart_transfer", undefined, "Restart transfer")}
</Button>
</div>
)}
{isPending && item.interrupted && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
<span className="min-w-0 flex-1">
{tr(
"queue_interrupted",
undefined,
"Stopped part-way. Resuming continues from what the PS5 already has.",
)}
</span>
<Button
size="sm"
variant="secondary"
onClick={() =>
void useUploadQueueStore
.getState()
.startHost(hostOf(item.addr), { onlyIds: [item.id] })
}
>
{tr("queue_resume", undefined, "Resume")}
</Button>
</div>
)}
{showInstallProgress && <InstallProgressBlock item={item} />}
{isActive && !showInstallProgress && !isRecovering && (
+40 -1
View File
@@ -274,6 +274,12 @@ export interface QueueItem {
* this id, so Stop and Start continue from the console's journal. */
txIdHex: string;
status: QueueItemStatus;
/** It was running when it was stopped or the app closed: it is waiting with its console-side
* progress kept, and starting it continues from there. Cleared when it runs again. */
interrupted?: boolean;
/** While running: how long nothing has moved, once that is STALL_AFTER_MS or more (the row then
* offers to restart the transfer, which resumes). Null otherwise. */
stalledForMs?: number | null;
/** Live progress while running, final count when done, 0 otherwise. */
bytesSent: number;
/** Total bytes the engine pre-stat'd for this source. 0 until first
@@ -427,6 +433,9 @@ interface QueueState {
moveDown: (id: string) => void;
clear: () => void;
retryFailed: () => void;
/** Stops a running (stalled) upload and starts it again; it continues from what the console
* already holds. Not for installs, which cannot be stopped. */
restartItem: (id: string) => void;
/** Retry one failed row. Returns false when the row is missing/not failed. */
retryItem: (id: string) => boolean;
/** Retry one archive row that failed for a missing or wrong password, with the password
@@ -470,6 +479,8 @@ interface QueueDocument {
}
const POLL_INTERVAL_MS = 500;
/** No new bytes for this long while sending (not while the console finalizes): say so on the row. */
const STALL_AFTER_MS = 90_000;
const SAVE_DEBOUNCE_MS = 300;
function newId(): string {
@@ -1174,6 +1185,9 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
// next runOne builds a fresh array.
const startedAtMs = Date.now();
const samples: RateSample[] = [{ ts: startedAtMs, bytes: 0 }];
// When bytes last moved, for the "nothing is moving" note on the row.
let lastBytes = -1;
let lastMoveAt = startedAtMs;
while (isLive()) {
const snap = await jobStatus(jobId, item.addr);
@@ -1564,11 +1578,20 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
const bytesSent = snap.bytes_sent ?? 0;
pushRateSample(samples, now, bytesSent);
const bytesPerSec = computeRate(samples, now);
if (bytesSent !== lastBytes) {
lastBytes = bytesSent;
lastMoveAt = now;
}
const totalNow = snap.total_bytes ?? 0;
// Everything sent and the console making it durable is not a stall.
const sending = !(totalNow > 0 && bytesSent >= totalNow);
const stalledForMs = sending && now - lastMoveAt >= STALL_AFTER_MS ? now - lastMoveAt : null;
set((s) => ({
items: patchItem(s.items, item.id, {
bytesSent,
totalBytes: snap.total_bytes ?? 0,
totalBytes: totalNow,
bytesPerSec,
stalledForMs,
// P3 / v2.18.0 — forward apply-phase counters so the
// QueueRow's finalize pill can show "Finalized N of M files"
// (analogous to transfer.ts's single-shot path). Defaults to
@@ -1644,6 +1667,8 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
set((s) => ({
items: patchItem(s.items, next.id, {
status: "running",
interrupted: false,
stalledForMs: null,
startedAt,
// Reset live counters so a previously-failed-then-retried
// item starts the bar + speed readout from zero instead
@@ -1924,6 +1949,8 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
if (!isTauriEnv()) reattaching.add(next.id);
}
next.status = "pending";
next.interrupted = true;
next.stalledForMs = null;
}
if (!next.txIdHex) next.txIdHex = generateTxIdHex();
// Back-fill the bytes/sec field added in 2.2.22 — older
@@ -2264,6 +2291,17 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
),
}));
scheduleSave();
// The button says Retry: run them, don't just put them back in the list.
void get().start();
},
restartItem(id) {
const item = get().items.find((candidate) => candidate.id === id);
if (!item || item.status !== "running" || item.sourceKind === "install") return;
const host = hostOf(item.addr);
get().stopHost(host);
// Let the engine let go of the job; the next run resumes it from the console's journal.
setTimeout(() => void get().startHost(host, { onlyIds: [id] }), 1500);
},
retryItem(id) {
@@ -2524,6 +2562,7 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
moveDown: () => undefined,
clear: () => undefined,
retryFailed: () => undefined,
restartItem: () => undefined,
retryItem: () => false,
retryWithPassword: () => false,
discardPartial: () => Promise.resolve(),
+60
View File
@@ -756,3 +756,63 @@ fn a_sync_batch_that_runs_out_of_memory_gives_its_pending_slots_back() {
"the failed batch leaked its pending-fd reservations"
);
}
/// Waits up to 5 s for `p` to exist.
fn appears(p: &Path) -> bool {
let t = std::time::Instant::now();
while t.elapsed().as_secs() < 5 {
if p.exists() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
false
}
/// A game's param.json is what makes a folder a game to ShadowMount+, which registered a folder
/// whose eboot.bin was still arriving and then gave up on it. It appears only when the rest has.
fn param_json_is_placed_last(merge: bool) {
let t = tmp(if merge { "held-merge" } else { "held-staged" });
let root = t.join("PPSA00001-app");
if merge {
std::fs::create_dir_all(&root).unwrap();
}
let base = if merge {
root.clone()
} else {
std::path::PathBuf::from(format!("{}.ava-part", root.display()))
};
let d = data(3 * GROUP as usize + 5, 7);
let m = Manifest {
entries: vec![
dir("sce_sys"),
file("sce_sys/param.json", 2),
file("eboot.bin", d.len() as u64),
],
};
let job = CApplyJob::begin(&t.join("jobs"), &root, 0, &m, 0);
job.record(1, b"{}", *blake3::hash(b"{}").as_bytes());
assert!(appears(&base.join("sce_sys/param.json.ava-hold")));
assert!(
!base.join("sce_sys/param.json").exists(),
"not before eboot.bin"
);
send_large(&job, 2, &d, false);
assert_eq!(job.wait(10_000), 0, "{}", job.events());
assert_eq!(
std::fs::read(root.join("sce_sys/param.json")).unwrap(),
b"{}"
);
assert_eq!(std::fs::read(root.join("eboot.bin")).unwrap(), d);
assert!(!root.join("sce_sys/param.json.ava-hold").exists());
}
#[test]
fn a_games_param_json_appears_only_when_the_staged_tree_lands() {
param_json_is_placed_last(false);
}
#[test]
fn a_games_param_json_appears_last_in_a_merge_too() {
param_json_is_placed_last(true);
}
+71
View File
@@ -270,3 +270,74 @@ async fn a_resumed_job_gets_the_resume_limit() {
srv.knob("progress_ms", 0);
srv.knob("resume_progress_ms", 0);
}
/// SkipProgress (SPEC.md §12.8): a resumed sender that is reading past data the receiver already
/// holds (a solid archive decoded from its start) sends no data frame for a long time, only
/// SkipProgress. That is progress: the job outlives its progress limit many times over and then
/// finishes normally. Without the frames the same job is cut (`a_resumed_job_gets_the_resume_limit`).
#[tokio::test(flavor = "multi_thread")]
async fn skip_progress_keeps_a_resumed_job_alive_past_its_limit() {
let (srv, d, ids) = server("stall-skip", 0);
srv.knob("progress_ms", 800);
srv.knob("resume_progress_ms", 1500);
let job = [0x66; 16];
{
let (mut link, s) = opened(&srv, &d, ids.clone(), job, 3).await;
let lane = link.opener().unwrap().open().await.unwrap();
link.lane(lane)
.unwrap()
.tx
.send_raw(Bundle::TYPE, 0, 1, bundle(job, 0, b"one!"))
.await
.unwrap();
loop {
if next_control(&mut link).await.ty == Durable::TYPE {
break;
}
}
drop(link);
s.close().await;
}
let (mut link, _s) = opened(&srv, &d, ids.clone(), job, 3).await;
let lane = link.opener().unwrap().open().await.unwrap();
// 4.8 s of nothing but SkipProgress, 3x the resume limit.
let t0 = Instant::now();
let mut skipped = 0u64;
while t0.elapsed() < Duration::from_millis(4800) {
skipped += 64 << 20;
link.control
.send(&gen::SkipProgress {
job_id: job,
bytes_skipped: skipped,
})
.await
.unwrap();
let until = Instant::now() + Duration::from_millis(400);
while let Ok(ev) = tokio::time::timeout_at(until.into(), link.rx.recv()).await {
if let Some(Inbound::Control(f)) = ev {
if f.ty == JobDone::TYPE {
let done: JobDone = f.decode().unwrap();
panic!(
"the job ended after {:?} of SkipProgress: {} {:?}",
t0.elapsed(),
done.status,
done.message
);
}
}
}
}
for (seq, id) in [(1u32, 1u32), (2, 2)] {
link.lane(lane)
.unwrap()
.tx
.send_raw(Bundle::TYPE, 0, seq, bundle(job, id, b"more"))
.await
.unwrap();
}
let done = job_done(&mut link, Duration::from_secs(30)).await;
assert_eq!(done.status, 0, "{:?}", done.message);
assert_eq!(std::fs::read(d.join("dest/f2")).unwrap(), b"more");
srv.knob("progress_ms", 0);
srv.knob("resume_progress_ms", 0);
}
+40 -1
View File
@@ -1576,6 +1576,43 @@ impl FrameMessage for JobCancel {
const TYPE: u8 = 0x2f;
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SkipProgress {
pub job_id: [u8; 16],
pub bytes_skipped: u64,
}
impl Message for SkipProgress {
const NAME: &'static str = "SkipProgress";
fn encode_into(&self, w: &mut Writer) -> Result<(), EncodeError> {
w.fixed(&self.job_id);
w.u64(self.bytes_skipped);
w.u16(0);
Ok(())
}
fn decode(b: &[u8]) -> Result<Self, DecodeError> {
let mut r = Reader::new(b);
let mut m = Self::default();
m.job_id = r.fixed::<16>()?;
m.bytes_skipped = r.u64()?;
let ext_n = r.u16()?;
for _ in 0..ext_n {
let tag = r.u16()?;
let len = r.u32()? as usize;
let v = r.take(len)?;
let _ = (tag, v);
}
r.finish()?;
Ok(m)
}
}
impl FrameMessage for SkipProgress {
const TYPE: u8 = 0x30;
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct NodeInfo {
pub version: String,
@@ -3359,7 +3396,7 @@ impl Message for JnlDone {
}
/// Every message and struct, by name (conformance tests).
pub const ALL: &[&str] = &["Hs1", "Hs2", "Hs3", "Welcome", "PairPakeClient", "PairPakeServer", "PairConfirm", "PairResult", "Join", "JoinAck", "Ping", "Pong", "Error", "Bye", "RpcRequest", "RpcResponse", "JobOpen", "JobOpenAck", "ManifestPage", "ManifestEnd", "JobMap", "Resume", "Chunk", "Bundle", "Received", "Credit", "Durable", "FileRoot", "FileRetry", "Status", "JobDone", "JobCancel", "NodeInfo", "HelloInfo", "ServerInfo", "ClientInfo", "PairingOpen", "CryptoBench", "CryptoBenchResult", "ManifestEntry", "FileRun", "FileRange", "BundleRecord", "RootItem", "JobCopy", "JobRef", "DiskCalibrate", "CalPoint", "DiskCalibrateResult", "C2cAllow", "C2cTicket", "C2cSend", "MgmtText", "NodeStatus", "FsList", "FsEntry", "FsListResult", "FsPath", "FsStat", "FsFreeSpace", "FsMkdir", "FsRename", "FsChmod", "FsRead", "FsReadResult", "FsWrite", "JobRun", "JobEntry", "JobListResult", "JnlOpen", "JnlBatch", "PackRef", "JnlSweep", "JnlReset", "JnlSnapshot", "JnlDone", ];
pub const ALL: &[&str] = &["Hs1", "Hs2", "Hs3", "Welcome", "PairPakeClient", "PairPakeServer", "PairConfirm", "PairResult", "Join", "JoinAck", "Ping", "Pong", "Error", "Bye", "RpcRequest", "RpcResponse", "JobOpen", "JobOpenAck", "ManifestPage", "ManifestEnd", "JobMap", "Resume", "Chunk", "Bundle", "Received", "Credit", "Durable", "FileRoot", "FileRetry", "Status", "JobDone", "JobCancel", "SkipProgress", "NodeInfo", "HelloInfo", "ServerInfo", "ClientInfo", "PairingOpen", "CryptoBench", "CryptoBenchResult", "ManifestEntry", "FileRun", "FileRange", "BundleRecord", "RootItem", "JobCopy", "JobRef", "DiskCalibrate", "CalPoint", "DiskCalibrateResult", "C2cAllow", "C2cTicket", "C2cSend", "MgmtText", "NodeStatus", "FsList", "FsEntry", "FsListResult", "FsPath", "FsStat", "FsFreeSpace", "FsMkdir", "FsRename", "FsChmod", "FsRead", "FsReadResult", "FsWrite", "JobRun", "JobEntry", "JobListResult", "JnlOpen", "JnlBatch", "PackRef", "JnlSweep", "JnlReset", "JnlSnapshot", "JnlDone", ];
#[doc(hidden)]
pub fn sample(name: &str, rng: &mut SplitMix) -> Option<Vec<u8>> {
@@ -3396,6 +3433,7 @@ pub fn sample(name: &str, rng: &mut SplitMix) -> Option<Vec<u8>> {
"Status" => Status { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, files_done: rng.next_u64() as u32, files_total: rng.next_u64() as u32, bytes_received: rng.next_u64(), bytes_durable: rng.next_u64(), bytes_total: rng.next_u64(), bottleneck: rng.next_u64() as u8, workers: rng.next_u64() as u8, lanes: rng.next_u64() as u8, sequential: rng.next_u64() as u8, current: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, state: if rng.below(2) == 1 { Some(rng.next_u64() as u8) } else { None }, result: if rng.below(2) == 1 { Some({ let n = rng.below(41) as usize; let mut v = vec![0u8; n]; rng.fill(&mut v); v }) } else { None }, code: if rng.below(2) == 1 { Some(rng.next_u64() as u16) } else { None }, unswept: if rng.below(2) == 1 { Some(rng.next_u64() as u32) } else { None }, }.to_bytes().ok(),
"JobDone" => JobDone { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, status: rng.next_u64() as u16, files: rng.next_u64() as u32, bytes: rng.next_u64(), message: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, settling: if rng.below(2) == 1 { Some(rng.next_u64() as u8) } else { None }, }.to_bytes().ok(),
"JobCancel" => JobCancel { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, reason: rng.next_u64() as u16, }.to_bytes().ok(),
"SkipProgress" => SkipProgress { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, bytes_skipped: rng.next_u64(), }.to_bytes().ok(),
"NodeInfo" => NodeInfo { version: rng.ascii(20), platform: rng.ascii(20), name: rng.ascii(20), firmware: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, }.to_bytes().ok(),
"HelloInfo" => HelloInfo { version_min: rng.next_u64() as u16, version_max: rng.next_u64() as u16, caps: rng.next_u64(), }.to_bytes().ok(),
"ServerInfo" => ServerInfo { version: rng.next_u64() as u16, caps: rng.next_u64(), session_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, pair_commit: { let mut a = [0u8; 32]; rng.fill(&mut a); a }, name: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, }.to_bytes().ok(),
@@ -3482,6 +3520,7 @@ pub fn roundtrip(name: &str, bytes: &[u8]) -> Option<Result<Vec<u8>, String>> {
"Status" => rt::<Status>(bytes),
"JobDone" => rt::<JobDone>(bytes),
"JobCancel" => rt::<JobCancel>(bytes),
"SkipProgress" => rt::<SkipProgress>(bytes),
"NodeInfo" => rt::<NodeInfo>(bytes),
"HelloInfo" => rt::<HelloInfo>(bytes),
"ServerInfo" => rt::<ServerInfo>(bytes),
+6
View File
@@ -1837,6 +1837,12 @@ async fn run_loop(
message: "the sender cancelled".into(),
});
}
// The sender is reading past data this job already holds (SPEC.md §12.8):
// progress, and nothing else.
gen::SkipProgress::TYPE => {
f.decode::<gen::SkipProgress>().map_err(proto)?;
last_progress = Instant::now();
}
_ => {}
},
Some(Inbound::LaneUp(_)) | Some(Inbound::LaneDown(_)) => {}
+58
View File
@@ -587,6 +587,18 @@ fn stall_limit(outstanding: u64) -> Duration {
/// console's job thread in one sync for minutes.
pub const RECEIVER_SILENT_FATAL: Duration = Duration::from_secs(600);
/// How often a sender that is reading past data the receiver already holds (a solid
/// archive decoded from its start on resume) tells it so with `SkipProgress` (SPEC.md §12.8).
/// The spec asks for at least every 10 s; the console's progress deadline is far longer.
pub const SKIP_PROGRESS_EVERY: Duration = Duration::from_secs(5);
const _: () = assert!(SKIP_PROGRESS_EVERY.as_secs() <= 10, "SPEC.md §12.8");
/// Whether a `SkipProgress` is due: the skip counter moved since the last one and the
/// interval has passed.
fn skip_progress_due(skipped: u64, last_sent: u64, since_last: Duration) -> bool {
skipped != last_sent && since_last >= SKIP_PROGRESS_EVERY
}
fn receiver_silent(limit: Duration) -> SendError {
SendError::Protocol(format!(
"the receiver said nothing about the job for {limit:?} although the connection is alive"
@@ -1492,6 +1504,17 @@ pub async fn run_upload(
let seq_retries = Arc::new(crate::seq::Retries::default());
let budget_wait: Arc<crate::seq::BudgetWait> = Arc::default();
let mut budget_wait_seen = 0u64;
// Bytes read past without sending (SPEC.md §12.8): the decode thread's own (held
// stretches of a partly durable file) plus the source's (entries decoded to discard).
let seq_skipped = Arc::new(AtomicU64::new(0));
let skip_base = opts.seq.as_ref().map_or(0, |s| s.bytes_skipped());
let skipped_now = |seq: &Arc<dyn crate::seq::SeqSource>| {
seq.bytes_skipped()
.saturating_sub(skip_base)
.saturating_add(seq_skipped.load(Ordering::Relaxed))
};
let (mut skip_sent, mut skip_sent_at) = (0u64, Instant::now());
let mut skip_next: Option<u64> = None;
let mut readers = if let Some(seq) = opts.seq.clone() {
// One decode thread for a forward-only source (SPEC.md §17).
let chunk_sh = sh.clone();
@@ -1514,6 +1537,7 @@ pub async fn run_upload(
cancel: opts.cancel.clone(),
retries: seq_retries.clone(),
rt: tokio::runtime::Handle::current(),
skipped: seq_skipped.clone(),
})]
} else {
spawn_readers(
@@ -1667,6 +1691,20 @@ pub async fn run_upload(
}
root_next.pop_front();
},
skip_sent_r = async {
let bytes_skipped = skip_next.expect("guarded by `if skip_next.is_some()`");
link.control
.send(&gen::SkipProgress { job_id, bytes_skipped })
.await
}, if skip_next.is_some() => {
// Queued from the tick and sent here, like the roots: awaiting it inline
// would stop the inbox drain while the control outbox is full.
if let Err(e) = skip_sent_r {
break Err(SendError::Disconnected(e.to_string()));
}
skip_sent = skip_next.take().expect("guarded by `if skip_next.is_some()`");
skip_sent_at = Instant::now();
},
cancel_sent = async {
link.control
.send(&gen::JobCancel { job_id, reason: gen::ERR_IO })
@@ -1880,6 +1918,14 @@ pub async fn run_upload(
)));
}
}
// A long skip sends no data frame: tell the receiver it is progress, or its
// watchdog ends the job (SPEC.md §12.8). Older receivers ignore the frame.
if let Some(seq) = opts.seq.as_ref() {
let n = skipped_now(seq);
if skip_next.is_none() && skip_progress_due(n, skip_sent, skip_sent_at.elapsed()) {
skip_next = Some(n);
}
}
let secs = last_tick.elapsed().as_secs_f64();
last_tick = Instant::now();
let lanes_now = link.lanes().len() as u8;
@@ -2222,6 +2268,18 @@ mod tests {
m
}
#[test]
fn skip_progress_is_sent_only_when_the_skip_moved_and_the_interval_passed() {
let every = SKIP_PROGRESS_EVERY;
assert!(!skip_progress_due(0, 0, every * 10), "nothing skipped");
assert!(
!skip_progress_due(5, 5, every * 10),
"no new skip since the last"
);
assert!(!skip_progress_due(9, 5, every / 2), "too soon");
assert!(skip_progress_due(9, 5, every));
}
#[test]
fn a_fresh_job_needs_every_byte_and_a_resumed_one_only_the_rest() {
let cutoff = 1 << 20;
+30 -1
View File
@@ -11,7 +11,7 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
@@ -80,6 +80,14 @@ pub trait SeqSource: Send + Sync {
/// Called once when the upload ends, before the decode thread is joined; wakes a
/// decoder parked on something other than the sink. Must not block.
fn close(&self) {}
/// Bytes this source has decoded only to discard them (entries a pass skips but
/// cannot seek past, such as a solid archive's prefix on resume), over its
/// lifetime; never decreases. The sender turns its growth into `SkipProgress`
/// (SPEC.md §12.8) so the receiver does not take a long skip for a stalled sender.
/// A source that never discards keeps the default.
fn bytes_skipped(&self) -> u64 {
0
}
}
/// A job gets at most this many passes (the first plus retries); a further `FileRetry`
@@ -144,6 +152,10 @@ pub(crate) struct DecodeCtx {
pub cancel: Arc<AtomicBool>,
pub retries: Arc<Retries>,
pub rt: tokio::runtime::Handle,
/// Bytes the decode thread received and did not send: the durable (or CV-known)
/// stretches of a partly held large file (SPEC.md §17.2). Read with
/// `SeqSource::bytes_skipped` for `SkipProgress`.
pub skipped: Arc<AtomicU64>,
}
/// Per-file plan for a pass.
@@ -520,15 +532,19 @@ impl EntrySink for Feeder<'_> {
return Ok(());
}
let mut b = bytes;
// What the receiver already holds passes through here unsent (SPEC.md §12.8).
let mut unsent = 0u64;
while !b.is_empty() {
let cur = self.cur.as_mut().expect("an entry is open");
let Some(p) = cur.plan.get(cur.pi).copied() else {
cur.pos += b.len() as u64;
unsent += b.len() as u64;
break;
};
if cur.pos < p.offset {
let n = ((p.offset - cur.pos) as usize).min(b.len());
cur.pos += n as u64;
unsent += n as u64;
b = &b[n..];
continue;
}
@@ -543,11 +559,17 @@ impl EntrySink for Feeder<'_> {
let n = ((p.len as usize) - cur.buf.len()).min(b.len());
cur.buf.extend_from_slice(&b[..n]);
cur.pos += n as u64;
if !p.send {
unsent += n as u64;
}
b = &b[n..];
if cur.buf.len() as u64 == p.len {
self.finish_piece()?;
}
}
if unsent > 0 {
self.c.skipped.fetch_add(unsent, Ordering::Relaxed);
}
Ok(())
}
@@ -686,6 +708,7 @@ mod tests {
}
struct Run {
skipped: Arc<AtomicU64>,
budget: Arc<Semaphore>,
bw: Arc<BudgetWait>,
rx: mpsc::UnboundedReceiver<Read>,
@@ -711,7 +734,9 @@ mod tests {
);
let bw = Arc::new(BudgetWait::default());
let budget = Arc::new(Semaphore::new(budget_kib));
let skipped = Arc::new(AtomicU64::new(0));
let ctx = DecodeCtx {
skipped: skipped.clone(),
seq: s.clone(),
manifest: m.clone(),
need,
@@ -728,6 +753,7 @@ mod tests {
};
let h = std::thread::spawn(move || run(ctx));
Run {
skipped,
budget,
bw,
rx,
@@ -921,6 +947,8 @@ mod tests {
}
// Only group 2 is sent; group 1 was decoded and hashed (no CV), group 0 was not hashed.
assert_eq!(sent, [(2 * GROUP, 5000)]);
// Both held groups passed through unsent: that is what SkipProgress reports.
assert_eq!(r.skipped.load(Ordering::Relaxed), 2 * GROUP);
assert_eq!(root, root_of(&big));
let wants = s.wants.lock().unwrap();
assert!(
@@ -1124,6 +1152,7 @@ mod tests {
cancel: Arc::default(),
retries: Arc::default(),
rt: tokio::runtime::Handle::current(),
skipped: Arc::default(),
};
let h = std::thread::spawn(move || run(ctx));
tokio::time::sleep(Duration::from_millis(100)).await;
+41
View File
@@ -1194,6 +1194,47 @@ async fn a_slow_but_moving_sender_is_not_cut() {
);
}
/// SkipProgress (SPEC.md §12.8) is progress: a sender reading past data the receiver holds
/// sends only that for several deadlines, is not cut, and then finishes.
#[tokio::test(flavor = "multi_thread")]
async fn skip_progress_is_progress_for_the_rust_receiver() {
let (link, _s, mut ended, _d, n) =
deadline_job("rr-skip", 0x93, 2, Duration::from_millis(800), None).await;
let lane = link.opener().unwrap().open().await.unwrap();
let t0 = std::time::Instant::now();
let mut skipped = 0u64;
while t0.elapsed() < Duration::from_millis(3000) {
skipped += 1 << 20;
link.control
.send(&gen::SkipProgress {
job_id: [0x93; 16],
bytes_skipped: skipped,
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
ended.try_recv().is_err(),
"the job ended after {:?} of SkipProgress",
t0.elapsed()
);
}
for i in 0..n {
link.lane(lane)
.unwrap()
.tx
.send_raw(Bundle::TYPE, 0, i + 1, bundle_body([0x93; 16], i, b"data"))
.await
.unwrap();
}
let files = tokio::time::timeout(Duration::from_secs(20), ended.recv())
.await
.expect("the job ended")
.expect("a result")
.expect("it completed, was not cut");
assert_eq!(files, n);
}
/// Disk work in flight is progress too: a sync batch that outlasts the deadline (a slow
/// drive) must not read as a stalled sender.
#[tokio::test(flavor = "multi_thread")]
+18 -1
View File
@@ -13,7 +13,7 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use ava1::gen;
use ava1::manifest::{self, Entry, Manifest};
@@ -119,6 +119,8 @@ pub struct RarSource {
sizes: Vec<u64>,
/// Manifest id -> path.
by_id: Vec<String>,
/// Bytes decoded only to be discarded (solid: the entries a pass skips).
skipped: AtomicU64,
}
impl RarSource {
@@ -214,6 +216,7 @@ impl RarSource {
order,
sizes: layout.files.iter().map(|(_, s)| *s).collect(),
by_id,
skipped: AtomicU64::new(0),
},
))
}
@@ -222,6 +225,12 @@ impl RarSource {
self.solid
}
/// Bytes decoded only to be discarded (the "skipping data the console already has"
/// phase of a solid resume).
pub fn bytes_skipped(&self) -> u64 {
self.skipped.load(Ordering::Relaxed)
}
/// Test seam: replace the archive-order listing the entry positions come from
/// (simulates an archive whose listing and extraction orders disagree).
#[doc(hidden)]
@@ -287,6 +296,10 @@ impl RarWalkSink for Adapter<'_> {
fn end(&mut self) -> io::Result<()> {
self.sink.end()
}
fn skipped(&mut self, n: u64) {
self.src.skipped.fetch_add(n, Ordering::Relaxed);
}
}
impl SeqSource for RarSource {
@@ -342,6 +355,10 @@ impl SeqSource for RarSource {
.and_then(|p| self.ordinal.get(p))
.map_or(Restart::START, |o| Restart(*o))
}
fn bytes_skipped(&self) -> u64 {
RarSource::bytes_skipped(self)
}
}
/// The sender takes a `Source` even when a `SeqSource` feeds it (only `close()` is
+4
View File
@@ -495,6 +495,10 @@ impl SeqSource for SevenzSource {
fn restart_for(&self, file_id: u32) -> Restart {
Restart(self.restarts.get(file_id as usize).copied().unwrap_or(0))
}
fn bytes_skipped(&self) -> u64 {
SevenzSource::bytes_skipped(self)
}
}
/// A folder whose decode ended before its last wanted entry.
@@ -415,6 +415,10 @@ fn rar_solid_resume_still_correct() {
src.pass(Restart::START, &mut want, &mut rec, &AtomicBool::new(false))
.unwrap();
assert_eq!(rec.got, f.0[7..].to_vec());
// The held prefix was decoded to be discarded: the sender reports it as SkipProgress.
let held: u64 = f.0[..7].iter().map(|(_, d)| d.len() as u64).sum();
assert_eq!(src.bytes_skipped(), held);
assert_eq!(SeqSource::bytes_skipped(&src), held);
}
#[test]
+39 -5
View File
@@ -844,7 +844,10 @@ mod rar_support {
continue;
}
file_count += 1;
total_uncompressed += e.unpacked_size;
// An unknown-size entry reports a ~2^63 placeholder; summing it
// wrapped the total in release builds and broke the client's
// space check. Count it as 0, like the planner does.
total_uncompressed = add_rar_entry_size(total_uncompressed, e.unpacked_size);
if let Some(rel) = sanitize_rar_entry(&e.filename) {
param_hit = better_param_hit(param_hit, &rel);
}
@@ -1016,9 +1019,7 @@ mod rar_support {
{
continue;
}
if !rar_size_unknown(e.unpacked_size) {
total += e.unpacked_size;
}
total = add_rar_entry_size(total, e.unpacked_size);
files.push((rel, e.unpacked_size));
}
Ok((total, files))
@@ -1291,6 +1292,17 @@ mod rar_support {
size >= 1 << 62
}
/// Add one entry's declared size to a running total: an unknown size
/// (the placeholder above) contributes nothing, and the sum saturates
/// rather than wrapping (release builds have no overflow checks).
pub(crate) fn add_rar_entry_size(total: u64, size: u64) -> u64 {
if rar_size_unknown(size) {
total
} else {
total.saturating_add(size)
}
}
/// Learn the real size of every unknown-size entry by decoding it once (the
/// stream's own end and CRC define it). Decodes the whole archive when it is
/// solid; a non-solid archive only decodes the unknown entries.
@@ -1406,6 +1418,9 @@ mod rar_support {
fn begin(&mut self, path: &str) -> std::io::Result<()>;
fn data(&mut self, bytes: &[u8]) -> std::io::Result<()>;
fn end(&mut self) -> std::io::Result<()>;
/// `n` more bytes were decoded only to be discarded (a solid archive's entry the
/// walk does not deliver). Lets a sender report a long skip as progress.
fn skipped(&mut self, _n: u64) {}
}
fn classify(e: &UnrarError, encrypted_with_password: bool) -> RarFailKind {
@@ -1528,7 +1543,10 @@ mod rar_support {
// Solid: the entry must be decoded to keep the stream aligned, and
// RAR_SKIP never calls back, so stop could not be polled. Decode it
// through a discarding callback that does.
let r = header.read_to_fn(&mut |_: &[u8]| !cancelled());
let r = header.read_to_fn(&mut |b: &[u8]| {
sink.skipped(b.len() as u64);
!cancelled()
});
if cancelled() {
return Err(RarWalkError::Cancelled);
}
@@ -1577,6 +1595,8 @@ mod rar_support {
sink_err = Some(e);
return false;
}
} else {
sink.skipped(bytes.len() as u64);
}
true
});
@@ -1626,6 +1646,20 @@ mod rar_support {
.join(name)
}
#[test]
fn unknown_size_entries_do_not_wrap_the_total() {
// RAR5 "size unknown" comes back from UnRAR as INT64NDF.
let ndf = 0x7fff_ffff_7fff_ffffu64;
let sizes = [10u64, ndf, 20, ndf, ndf, u64::MAX];
let total = sizes.iter().fold(0, |t, &s| add_rar_entry_size(t, s));
assert_eq!(total, 30, "unknown sizes must be skipped, not summed");
assert_eq!(
add_rar_entry_size((1 << 62) - 1, (1 << 62) - 1),
(1 << 63) - 2
);
assert_eq!(add_rar_entry_size(u64::MAX - 1, 5), u64::MAX);
}
// crypted.rar: content-encrypted (names listable without a password),
// password "unrar", first entry ".gitignore" = "target\nCargo.lock\n".
#[test]
+55 -2
View File
@@ -72,17 +72,59 @@ static int in_the_way(int e) { return e == EEXIST || e == ENOTEMPTY || e == ENOT
#define parent_of ava1_parent_of
#define mkparents ava1_mkparents
/* A game's metadata (sce_sys/param.json, a PS4 game's sce_sys/param.sfo) is what makes a folder a
* game to ShadowMount+ and the other scanners, which register it as soon as sce_sys has sat still
* for a few seconds. Arriving first (it is small) it let them register a folder whose eboot.bin
* was still on its way: the mount failed, and ShadowMount+ gives up on a title after two failures,
* so the finished game never showed up. These files are written as <name>.ava-hold and renamed
* into place by release_held() once everything else in the job is. */
int ava1_apply_held_rel(const char *rel) {
static const char *const names[] = {"sce_sys/param.json", "sce_sys/param.sfo"};
size_t n, k, i;
if (!rel) return 0;
n = strlen(rel);
for (i = 0; i < sizeof names / sizeof names[0]; i++) {
k = strlen(names[i]);
if (n >= k && strcmp(rel + n - k, names[i]) == 0 && (n == k || rel[n - k - 1] == '/')) return 1;
}
return 0;
}
/* A path that does not fit comes back empty (open() then fails) rather than truncated
* (which would name some other file). */
* (which would name some other file). The "final" path of a held file is its .ava-hold name. */
void ava1_apply_path(const ava1_job_t *j, uint32_t id, int part, char *out, size_t cap) {
const char *rel = ava1_mstore_path(&j->m, id);
int n;
if (j->flags & AVA1_JF_SINGLE_FILE) n = snprintf(out, cap, "%s%s", j->root, part ? ".ava-part" : "");
else if (rel) n = snprintf(out, cap, "%s/%s%s", j->base, rel, part && !j->staged ? ".ava-part" : "");
else if (rel)
n = snprintf(out, cap, "%s/%s%s%s", j->base, rel, ava1_apply_held_rel(rel) ? ".ava-hold" : "",
part && !j->staged ? ".ava-part" : "");
else n = -1;
if (cap && (n < 0 || (size_t)n >= cap)) out[0] = 0;
}
/* Renames each held file of the finished tree at `tree` into place (same folder, so never across
* drives). A missing .ava-hold is one already released, or a file this job did not write. */
static int release_held(ava1_job_t *j, const char *tree) {
char hold[PATH_CAP], fin[PATH_CAP], parent[PATH_CAP];
uint32_t i;
struct stat st;
int e;
if (j->flags & AVA1_JF_SINGLE_FILE) return 0;
for (i = 0; i < j->m.n; i++) {
const char *rel = ava1_mstore_path(&j->m, i);
if (j->m.e[i].kind != AVA1_ENTRY_FILE || !ava1_apply_held_rel(rel)) continue;
if (snprintf(fin, sizeof fin, "%s/%s", tree, rel) >= (int)sizeof fin ||
snprintf(hold, sizeof hold, "%s.ava-hold", fin) >= (int)sizeof hold)
return ENAMETOOLONG;
if (lstat(hold, &st) != 0) continue;
if (rename(hold, fin) != 0) return errno;
parent_of(fin, parent, sizeof parent);
if ((e = sync_dir(parent)) != 0) return e;
}
return 0;
}
static void ob_path(const ava1_job_t *j, uint32_t id, char *out, size_t cap) {
snprintf(out, cap, "%s/%u.ob", j->dir, id);
}
@@ -3055,6 +3097,12 @@ static void finish(ava1_job_t *j) {
return;
}
}
/* Last, with every other file in place: the game's metadata (see ava1_apply_held_rel). A staged
* tree has just been renamed to the root; a merge wrote into it all along. */
if ((e = release_held(j, j->staged ? j->root : j->base)) != 0) {
ava1_apply_fail(j, AVA1_ERR_IO, "placing the game's param file failed", e, 1);
return;
}
ava1_apply_fail(j, AVA1_STATUS_OK, "", 0, 1); /* the success path: see ava1_apply_fail */
}
@@ -3062,6 +3110,11 @@ void ava1_apply_finish_landed(ava1_job_t *j) {
char parent[PATH_CAP];
int err;
parent_of(j->root, parent, sizeof parent);
err = release_held(j, j->root); /* stopped after the staging rename, before the release */
if (err) {
ava1_apply_fail(j, AVA1_ERR_IO, "placing the game's param file failed", err, 1);
return;
}
err = sync_dir(parent);
if (err) ava1_apply_fail(j, AVA1_ERR_IO, "syncing the landed folder failed", err, 1);
else ava1_apply_fail(j, AVA1_STATUS_OK, "", 0, 1);
+2
View File
@@ -48,6 +48,8 @@ int ava1_apply_root(ava1_job_t *j, uint32_t file_id, const uint8_t root[32]);
/* Runs fn(j, arg, 0..n-1) on the workers and waits for all of them. */
int ava1_apply_parallel(ava1_job_t *j, void (*fn)(ava1_job_t *, void *, uint32_t), void *arg, uint32_t n);
void ava1_apply_path(const ava1_job_t *j, uint32_t id, int part, char *out, size_t cap);
/* sce_sys/param.json or sce_sys/param.sfo at any depth: written as .ava-hold, placed at the end. */
int ava1_apply_held_rel(const char *rel);
/* Ends the job once (also the success path, status AVA1_STATUS_OK): journal Done when
* asked, then JobDone. */
/* The only way to append to a job's journal once workers run (they append commits): the
+12
View File
@@ -1569,6 +1569,16 @@ static int route(const uint8_t sid[16], const uint8_t peer[32], uint8_t type, co
if (mine) inbox_add(j, type, body, len);
break;
}
case AVA1_TYPE_SKIP_PROGRESS: {
/* SPEC.md §12.8: the sender is reading past data this job already holds (a solid
* archive decoded from its start on resume). It is progress for the watchdog, and
* nothing else: no inbox entry, no reply. Only from the session the job is
* attached to. */
pthread_mutex_lock(&j->cmu);
if (j->attached && memcmp(j->sid, sid, 16) == 0) j->frames_in++;
pthread_mutex_unlock(&j->cmu);
break;
}
default:
break; /* nothing else is for a receiver */
}
@@ -1585,6 +1595,7 @@ static int well_formed(uint8_t type, const uint8_t *body, size_t len) {
ava1_resume_t r;
ava1_file_root_t f;
ava1_job_cancel_t c;
ava1_skip_progress_t s;
} u;
switch (type) {
case AVA1_TYPE_JOB_OPEN: return ava1_job_open_decode(body, len, &u.o) == 0;
@@ -1593,6 +1604,7 @@ static int well_formed(uint8_t type, const uint8_t *body, size_t len) {
case AVA1_TYPE_RESUME: return ava1_resume_decode(body, len, &u.r) == 0;
case AVA1_TYPE_FILE_ROOT: return ava1_file_root_decode(body, len, &u.f) == 0;
case AVA1_TYPE_JOB_CANCEL: return ava1_job_cancel_decode(body, len, &u.c) == 0;
case AVA1_TYPE_SKIP_PROGRESS: return ava1_skip_progress_decode(body, len, &u.s) == 0;
default: return 1;
}
}
+31 -1
View File
@@ -3898,6 +3898,30 @@ int ava1_job_cancel_decode(const uint8_t *buf, size_t len, ava1_job_cancel_t *m)
return ava1_r_finish(&r);
}
int ava1_skip_progress_encode(const ava1_skip_progress_t *m, ava1_w_t *w) {
ava1_w_fixed(w, m->job_id, 16);
ava1_w_u64(w, m->bytes_skipped);
ava1_w_u16(w, 0);
return w->err;
}
int ava1_skip_progress_decode(const uint8_t *buf, size_t len, ava1_skip_progress_t *m) {
ava1_r_t r;
uint16_t ext_n, i;
memset(m, 0, sizeof(*m));
ava1_r_init(&r, buf, len);
ava1_r_fixed(&r, m->job_id, 16);
m->bytes_skipped = ava1_r_u64(&r);
ext_n = ava1_r_u16(&r);
for (i = 0; i < ext_n && !r.err; i++) {
uint32_t vlen;
(void)ava1_r_u16(&r);
vlen = ava1_r_u32(&r);
(void)ava1_r_take(&r, vlen);
}
return ava1_r_finish(&r);
}
const char *const ava1_message_names[] = {
"NodeInfo",
"HelloInfo",
@@ -3975,8 +3999,9 @@ const char *const ava1_message_names[] = {
"Status",
"JobDone",
"JobCancel",
"SkipProgress",
};
const size_t ava1_message_count = 76;
const size_t ava1_message_count = 77;
int ava1_roundtrip(const char *name, const uint8_t *in, size_t in_len, uint8_t *out, size_t cap,
size_t *out_len) {
@@ -4364,6 +4389,11 @@ int ava1_roundtrip(const char *name, const uint8_t *in, size_t in_len, uint8_t *
rc = ava1_job_cancel_decode(in, in_len, &m);
if (rc == 0) rc = ava1_job_cancel_encode(&m, &w);
}
else if (strcmp(name, "SkipProgress") == 0) {
ava1_skip_progress_t m;
rc = ava1_skip_progress_decode(in, in_len, &m);
if (rc == 0) rc = ava1_skip_progress_encode(&m, &w);
}
else {
return AVA1_E_PROTO;
}
+9
View File
@@ -224,6 +224,7 @@
#define AVA1_TYPE_STATUS 0x2du
#define AVA1_TYPE_JOB_DONE 0x2eu
#define AVA1_TYPE_JOB_CANCEL 0x2fu
#define AVA1_TYPE_SKIP_PROGRESS 0x30u
typedef struct {
const uint8_t *version;
@@ -1221,6 +1222,14 @@ typedef struct {
int ava1_job_cancel_encode(const ava1_job_cancel_t *m, ava1_w_t *w);
int ava1_job_cancel_decode(const uint8_t *buf, size_t len, ava1_job_cancel_t *m);
typedef struct {
uint8_t job_id[16];
uint64_t bytes_skipped;
} ava1_skip_progress_t;
int ava1_skip_progress_encode(const ava1_skip_progress_t *m, ava1_w_t *w);
int ava1_skip_progress_decode(const uint8_t *buf, size_t len, ava1_skip_progress_t *m);
extern const char *const ava1_message_names[];
extern const size_t ava1_message_count;
+2 -1
View File
@@ -41,7 +41,8 @@
* src/register.c 1 dir param.json: tmp -> final
* src/shell_builtin.c 1 guard `mv` (mv_same_dev, lstat of the source)
* ava1/ava1_events.c 1 dir events.log -> events.log.old
* ava1/ava1_apply.c 2 guard file and folder commit (same_device hook, only a definite 1 renames)
* ava1/ava1_apply.c 3 guard file and folder commit (same_device hook, only a definite 1 renames);
* held param.json/param.sfo: name.ava-hold -> name, same dir
* ava1/ava1_journal.c 1 dir journal: tmp -> final
* ava1/ava1_store.c 1 dir key and trust stores: tmp -> final
* ava1/ava1_recv.c 2 dir outboard renames inside the job folder
+9
View File
@@ -759,6 +759,7 @@ peers for the job (§11.3), not who opened it.
| 0x2D | `Status` | receiver → sender (IGNORABLE) | control |
| 0x2E | `JobDone{status, files, bytes}` | receiver → sender | control |
| 0x2F | `JobCancel` | either | control |
| 0x30 | `SkipProgress{bytes_skipped}` | sender → receiver | control |
`FileRetry.reason` is `RETRY_VERIFY` (1, the root did not match), `RETRY_IO` (2, the receiver lost
the part file or outboard) or `RETRY_CHANGED` (3, the record's length disagrees with the manifest).
@@ -869,6 +870,14 @@ and fails the job (the engine's session retry resumes it); the console ends it w
status `ERR_STALLED` and keeps the journal, as for every console-side failure. The sender
observes the code like any receiver-ended job and may resume.
`SkipProgress{job_id, bytes_skipped}` (0x30) is job-scoped liveness. A sender that reads past
data the receiver already holds (a solid archive decoded from its start on resume, §17.2, or the
durable stretches of a partly held large file) sends no data frame while it does; it sends a
`SkipProgress` at least every 10 s instead (the engine: every 5 s while its skip counter moves).
`bytes_skipped` is the running total the sender has read past for this job (informational). A
receiver counts it as progress for that job, only from the session the job is attached to, and
answers nothing. Receivers that predate it ignore it as an unknown data-plane type.
## 13. Verification
13.1 A file's root is its standard BLAKE3 hash. Files are hashed in groups of 1 MiB
+8
View File
@@ -595,6 +595,14 @@ name = "JobCancel"
type = 0x2F
fields = [{ name = "job_id", ty = "b16" }, { name = "reason", ty = "u16" }]
# Job-scoped liveness (SPEC.md §12.8): a sender reading past data the receiver already
# holds (a solid archive decoded from its start on resume) sends this at least every
# 10 s; the receiver counts it as progress for that job. Older receivers ignore it.
[[message]]
name = "SkipProgress"
type = 0x30
fields = [{ name = "job_id", ty = "b16" }, { name = "bytes_skipped", ty = "u64" }]
# RPC bodies (SPEC.md §16)
[[struct]]
name = "JobCopy"