mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-18 18:10:05 -05:00
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:
1 parent
42403e4892
commit
f61f5629e7
51 files changed
+1117
-40
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(_)) => {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user