mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-21 19:33:08 -05:00
Merge branch 'worktree-agent-a2ae9b624051a3566' into fix-469
This commit is contained in:
commit
c10100a719
46 files changed
+1695
-84
No files matched your search
@@ -2409,6 +2409,28 @@ pub async fn exfat_build(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Unpack a game image or a debug-key package into a plain folder; runs as an engine job.
|
||||
#[tauri::command]
|
||||
pub async fn folder_build(
|
||||
source: String,
|
||||
output_dir: Option<String>,
|
||||
name: Option<String>,
|
||||
full_verify: Option<bool>,
|
||||
) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
let url = format!("{base}/api/folder/build");
|
||||
post_json(
|
||||
&url,
|
||||
&serde_json::json!({
|
||||
"source": source,
|
||||
"output_dir": output_dir,
|
||||
"name": name,
|
||||
"full_verify": full_verify.unwrap_or(false),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn job_status(job_id: String) -> Result<JsonValue, String> {
|
||||
let base = engine::url();
|
||||
|
||||
@@ -332,6 +332,7 @@ pub fn run() {
|
||||
commands::ps5_elfldr_ensure,
|
||||
commands::ffpfsc_compress,
|
||||
commands::exfat_build,
|
||||
commands::folder_build,
|
||||
commands::fpkg_extract,
|
||||
commands::fpkg_extract_cleanup,
|
||||
commands::job_status,
|
||||
|
||||
@@ -152,4 +152,10 @@ export const fpkg = {
|
||||
verifyChunkKib,
|
||||
fullVerify,
|
||||
}),
|
||||
/** Unpack a game image (.exfat, .ffpkg, .ffpfs, .ffpfsc) or a package made with the debug
|
||||
* key into a plain folder in the output folder, named after the source (or `name`). One
|
||||
* job: written, read back (sizes, and SHA-256 of a sample or of every file), then given its
|
||||
* name; the job's `dest` is the folder. */
|
||||
buildFolder: (source: string, outputDir?: string, name?: string, fullVerify = false) =>
|
||||
invoke<{ job_id: string }>("folder_build", { source, outputDir, name, fullVerify }),
|
||||
};
|
||||
@@ -3987,6 +3987,19 @@ c2c_test_deep_bad: "لم يصل الإرسال التجريبي بحجم 4 KiB:
|
||||
"joberr.ava1_taken_over": "تولّى جهاز آخر هذا الإرسال؛ وهو يستمر هناك.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "لا يزال جهاز آخر يشغّل هذا الإرسال. يتطلب توليه (أو مشاهدته) أثناء التشغيل المساعد الأحدث على كلا الجهازين: حدّثهما، أو أوقف الإرسال مؤقتًا على ذلك الجهاز أولًا.",
|
||||
"joberr.ava1_cannot_watch": "يمر هذا الإرسال عبر الجهاز الآخر، لذا يمكن توليه لكن لا يمكن مشاهدته.",
|
||||
"fpkg.make.folder_title": "مجلد لعبة",
|
||||
"fpkg.make.folder_body": "ملفات اللعبة كمجلد عادي على هذا الكمبيوتر، من صورة لعبة أو من حزمة أُنشئت بمفتاح التصحيح. عدّله أو احتفظ به أو ارفعه مثل أي مجلد.",
|
||||
"fpkg.folder.what": "يُسمّى المجلد باسم الصورة أو الحزمة، داخل مجلد الإخراج. لا يُضغط شيء، ولا يصل شيء إلى PS5.",
|
||||
"cq_ext_folder": "مجلد",
|
||||
"fpkg.stage.write_folder": "كتابة المجلد",
|
||||
"fpkg.stage.verify_folder": "إعادة قراءة المجلد",
|
||||
"fpkg.folder.make": "إنشاء مجلد اللعبة",
|
||||
"fpkg.folder.already": "هذا مجلد بالفعل",
|
||||
"fpkg.folder.sourceOnly": "لا يمكن تحويل سوى صورة لعبة أو حزمة أُنشئت بمفتاح التصحيح إلى مجلد؛ فكّ ضغط الأرشيف أولًا.",
|
||||
"fpkg.folder.done": "تمت كتابة مجلد اللعبة وإعادة قراءته.",
|
||||
"cq_then_keep_folder": "الاحتفاظ بالمجلد",
|
||||
"cq_folder_sources_only": "لا يمكن تحويل سوى صورة لعبة أو حزمة أُنشئت بمفتاح التصحيح إلى مجلد: تم تخطي {count}.",
|
||||
"fpkg.run.what_folder": "مجلد لعبة",
|
||||
};
|
||||
|
||||
export default ar;
|
||||
@@ -3987,6 +3987,19 @@ c2c_test_deep_bad: "4 KiB-এর পরীক্ষামূলক পাঠা
|
||||
"joberr.ava1_taken_over": "অন্য একটি ডিভাইস এই পাঠানোর দায়িত্ব নিয়েছে; এটি সেখানে চলবে।",
|
||||
"joberr.ava1_shared_jobs_unsupported": "অন্য একটি ডিভাইস এখনও এই পাঠানো চালাচ্ছে। চলাকালীন এর দায়িত্ব নিতে (বা দেখতে) দুই কনসোলেই নতুন হেল্পার লাগবে: সেগুলো আপডেট করুন, বা আগে সেই ডিভাইসে পাঠানো বিরতি দিন।",
|
||||
"joberr.ava1_cannot_watch": "এই পাঠানো অন্য ডিভাইসের মাধ্যমে যায়, তাই দায়িত্ব নেওয়া যায় কিন্তু দেখা যায় না।",
|
||||
"fpkg.make.folder_title": "একটি গেম ফোল্ডার",
|
||||
"fpkg.make.folder_body": "গেমের ফাইলগুলো এই কম্পিউটারে একটি সাধারণ ফোল্ডার হিসেবে, একটি গেম ইমেজ বা ডিবাগ কী দিয়ে তৈরি প্যাকেজ থেকে। যেকোনো ফোল্ডারের মতো এটি সম্পাদনা করুন, রাখুন বা আপলোড করুন।",
|
||||
"fpkg.folder.what": "ফোল্ডারটির নাম ইমেজ বা প্যাকেজের নামে হয়, আউটপুট ফোল্ডারে। কিছুই সংকুচিত হয় না, আর PS5-এ কিছুই পাঠানো হয় না।",
|
||||
"cq_ext_folder": "ফোল্ডার",
|
||||
"fpkg.stage.write_folder": "ফোল্ডার লিখুন",
|
||||
"fpkg.stage.verify_folder": "ফোল্ডার আবার পড়ুন",
|
||||
"fpkg.folder.make": "গেম ফোল্ডার বানান",
|
||||
"fpkg.folder.already": "এটি আগে থেকেই একটি ফোল্ডার",
|
||||
"fpkg.folder.sourceOnly": "শুধু গেম ইমেজ বা ডিবাগ কী দিয়ে তৈরি প্যাকেজই ফোল্ডার হতে পারে; আগে আর্কাইভটি আনপ্যাক করুন।",
|
||||
"fpkg.folder.done": "গেম ফোল্ডার লেখা ও আবার পড়ে যাচাই করা হয়েছে।",
|
||||
"cq_then_keep_folder": "ফোল্ডার রাখুন",
|
||||
"cq_folder_sources_only": "শুধু গেম ইমেজ বা ডিবাগ কী দিয়ে তৈরি প্যাকেজই ফোল্ডার হতে পারে: {count}টি বাদ দেওয়া হয়েছে।",
|
||||
"fpkg.run.what_folder": "একটি গেম ফোল্ডার",
|
||||
};
|
||||
|
||||
export default bn;
|
||||
@@ -3995,6 +3995,19 @@ c2c_test_deep_bad: "Die 4-KiB-Testsendung ist nicht angekommen: {detail}",
|
||||
"joberr.ava1_taken_over": "Ein anderes Gerät hat diese Sendung übernommen; sie läuft dort weiter.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Ein anderes Gerät führt diese Sendung noch aus. Sie während des Laufs zu übernehmen (oder anzusehen) erfordert den neueren Helfer auf beiden Konsolen: Aktualisiere sie oder pausiere die Sendung zuerst auf jenem Gerät.",
|
||||
"joberr.ava1_cannot_watch": "Diese Sendung läuft über das andere Gerät, daher kann sie übernommen, aber nicht angesehen werden.",
|
||||
"fpkg.make.folder_title": "Ein Spielordner",
|
||||
"fpkg.make.folder_body": "Die Dateien des Spiels als normaler Ordner auf diesem Computer, aus einem Spiel-Image oder einem mit dem Debug-Schlüssel erstellten Paket. Bearbeite ihn, behalte ihn oder lade ihn wie jeden Ordner hoch.",
|
||||
"fpkg.folder.what": "Der Ordner wird nach dem Image oder Paket benannt und landet im Ausgabeordner. Nichts wird komprimiert, und nichts gelangt zur PS5.",
|
||||
"cq_ext_folder": "Ordner",
|
||||
"fpkg.stage.write_folder": "Ordner schreiben",
|
||||
"fpkg.stage.verify_folder": "Ordner zurücklesen",
|
||||
"fpkg.folder.make": "Spielordner erstellen",
|
||||
"fpkg.folder.already": "Das ist bereits ein Ordner",
|
||||
"fpkg.folder.sourceOnly": "Nur ein Spiel-Image oder ein mit dem Debug-Schlüssel erstelltes Paket kann zu einem Ordner werden; entpacke zuerst ein Archiv.",
|
||||
"fpkg.folder.done": "Spielordner geschrieben und zurückgelesen.",
|
||||
"cq_then_keep_folder": "Ordner behalten",
|
||||
"cq_folder_sources_only": "Nur ein Spiel-Image oder ein mit dem Debug-Schlüssel erstelltes Paket kann zu einem Ordner werden: {count} übersprungen.",
|
||||
"fpkg.run.what_folder": "einen Spielordner",
|
||||
};
|
||||
|
||||
export default de;
|
||||
@@ -4310,6 +4310,19 @@ c2c_test_deep_bad: "The 4 KiB test send did not arrive: {detail}",
|
||||
"joberr.ava1_taken_over": "Another device took over this send; it continues there.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Another device is still running this send. Taking it over (or watching it) while it runs needs the newer helper on both consoles: update them, or pause the send on that device first.",
|
||||
"joberr.ava1_cannot_watch": "This send goes through the other device, so it can be taken over but not watched.",
|
||||
"fpkg.make.folder_title": "A game folder",
|
||||
"fpkg.make.folder_body": "The game's files as a plain folder on this computer, from a game image or a package made with the debug key. Edit it, keep it, or upload it like any folder.",
|
||||
"fpkg.folder.what": "The folder is named after the image or package, in the output folder. Nothing is compressed, and nothing reaches the PS5.",
|
||||
"cq_ext_folder": "folder",
|
||||
"fpkg.stage.write_folder": "Write folder",
|
||||
"fpkg.stage.verify_folder": "Read the folder back",
|
||||
"fpkg.folder.make": "Make game folder",
|
||||
"fpkg.folder.already": "This is already a folder",
|
||||
"fpkg.folder.sourceOnly": "Only a game image or a package made with the debug key can become a folder; unpack an archive first.",
|
||||
"fpkg.folder.done": "Game folder written and read back.",
|
||||
"cq_then_keep_folder": "Keep the folder",
|
||||
"cq_folder_sources_only": "Only a game image or a package made with the debug key can become a folder: {count} skipped.",
|
||||
"fpkg.run.what_folder": "a game folder",
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -3937,6 +3937,19 @@ c2c_test_deep_bad: "El envío de prueba de 4 KiB no llegó: {detail}",
|
||||
"joberr.ava1_taken_over": "Otro dispositivo tomó el control de este envío; continúa allí.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Otro dispositivo sigue ejecutando este envío. Tomar el control (o verlo) mientras se ejecuta requiere el asistente más reciente en ambas consolas: actualízalos o pon el envío en pausa primero en ese dispositivo.",
|
||||
"joberr.ava1_cannot_watch": "Este envío pasa por el otro dispositivo, así que se puede tomar el control, pero no verlo.",
|
||||
"fpkg.make.folder_title": "Una carpeta de juego",
|
||||
"fpkg.make.folder_body": "Los archivos del juego como una carpeta normal en este equipo, a partir de una imagen de juego o de un paquete creado con la clave de depuración. Edítala, guárdala o súbela como cualquier carpeta.",
|
||||
"fpkg.folder.what": "La carpeta lleva el nombre de la imagen o del paquete y se crea en la carpeta de salida. No se comprime nada y nada llega a la PS5.",
|
||||
"cq_ext_folder": "carpeta",
|
||||
"fpkg.stage.write_folder": "Escribir carpeta",
|
||||
"fpkg.stage.verify_folder": "Releer la carpeta",
|
||||
"fpkg.folder.make": "Crear carpeta del juego",
|
||||
"fpkg.folder.already": "Esto ya es una carpeta",
|
||||
"fpkg.folder.sourceOnly": "Solo una imagen de juego o un paquete creado con la clave de depuración puede convertirse en carpeta; descomprime antes el archivo comprimido.",
|
||||
"fpkg.folder.done": "Carpeta del juego escrita y releída.",
|
||||
"cq_then_keep_folder": "Conservar la carpeta",
|
||||
"cq_folder_sources_only": "Solo una imagen de juego o un paquete creado con la clave de depuración puede convertirse en carpeta: {count} omitidos.",
|
||||
"fpkg.run.what_folder": "una carpeta de juego",
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -4288,6 +4288,19 @@ c2c_test_deep_bad: "ارسال آزمایشی ۴ KiB نرسید: {detail}",
|
||||
"joberr.ava1_taken_over": "دستگاه دیگری این ارسال را در دست گرفت؛ آنجا ادامه مییابد.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "دستگاه دیگری هنوز این ارسال را اجرا میکند. در دست گرفتن (یا تماشای) آن در حین اجرا به دستیار جدیدتر روی هر دو کنسول نیاز دارد: آنها را بهروز کنید یا ابتدا ارسال را روی آن دستگاه متوقف موقت کنید.",
|
||||
"joberr.ava1_cannot_watch": "این ارسال از طریق دستگاه دیگر میگذرد، پس میتوان آن را در دست گرفت اما نمیتوان تماشا کرد.",
|
||||
"fpkg.make.folder_title": "یک پوشهٔ بازی",
|
||||
"fpkg.make.folder_body": "فایلهای بازی بهصورت یک پوشهٔ معمولی روی این رایانه، از ایمیج بازی یا بستهای که با کلید دیباگ ساخته شده است. مثل هر پوشهٔ دیگری ویرایشش کنید، نگهش دارید یا آپلودش کنید.",
|
||||
"fpkg.folder.what": "نام پوشه از روی ایمیج یا بسته گذاشته میشود و در پوشهٔ خروجی ساخته میشود. هیچ چیزی فشرده نمیشود و چیزی به PS5 نمیرسد.",
|
||||
"cq_ext_folder": "پوشه",
|
||||
"fpkg.stage.write_folder": "نوشتن پوشه",
|
||||
"fpkg.stage.verify_folder": "بازخوانی پوشه",
|
||||
"fpkg.folder.make": "ساخت پوشهٔ بازی",
|
||||
"fpkg.folder.already": "این از قبل یک پوشه است",
|
||||
"fpkg.folder.sourceOnly": "فقط ایمیج بازی یا بستهای که با کلید دیباگ ساخته شده میتواند پوشه شود؛ اول آرشیو را باز کنید.",
|
||||
"fpkg.folder.done": "پوشهٔ بازی نوشته و بازخوانی شد.",
|
||||
"cq_then_keep_folder": "نگهداشتن پوشه",
|
||||
"cq_folder_sources_only": "فقط ایمیج بازی یا بستهای که با کلید دیباگ ساخته شده میتواند پوشه شود: {count} مورد رد شد.",
|
||||
"fpkg.run.what_folder": "یک پوشهٔ بازی",
|
||||
};
|
||||
|
||||
export default fa;
|
||||
@@ -4005,6 +4005,19 @@ c2c_test_deep_bad: "L’envoi test de 4 Kio n’est pas arrivé : {detail}",
|
||||
"joberr.ava1_taken_over": "Un autre appareil a repris la main sur cet envoi ; il continue là-bas.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Un autre appareil exécute encore cet envoi. En reprendre la main (ou le suivre) pendant qu’il tourne nécessite le nouvel assistant sur les deux consoles : mettez-les à jour, ou mettez d’abord l’envoi en pause sur cet appareil.",
|
||||
"joberr.ava1_cannot_watch": "Cet envoi passe par l’autre appareil : on peut en reprendre la main, mais pas le suivre.",
|
||||
"fpkg.make.folder_title": "Un dossier de jeu",
|
||||
"fpkg.make.folder_body": "Les fichiers du jeu sous forme de dossier ordinaire sur cet ordinateur, à partir d'une image de jeu ou d'un paquet créé avec la clé de débogage. Modifiez-le, gardez-le ou envoyez-le comme n'importe quel dossier.",
|
||||
"fpkg.folder.what": "Le dossier porte le nom de l'image ou du paquet, dans le dossier de sortie. Rien n'est compressé et rien n'est envoyé à la PS5.",
|
||||
"cq_ext_folder": "dossier",
|
||||
"fpkg.stage.write_folder": "Écrire le dossier",
|
||||
"fpkg.stage.verify_folder": "Relire le dossier",
|
||||
"fpkg.folder.make": "Créer le dossier du jeu",
|
||||
"fpkg.folder.already": "C'est déjà un dossier",
|
||||
"fpkg.folder.sourceOnly": "Seule une image de jeu ou un paquet créé avec la clé de débogage peut devenir un dossier ; décompressez d'abord l'archive.",
|
||||
"fpkg.folder.done": "Dossier du jeu écrit et relu.",
|
||||
"cq_then_keep_folder": "Garder le dossier",
|
||||
"cq_folder_sources_only": "Seule une image de jeu ou un paquet créé avec la clé de débogage peut devenir un dossier : {count} ignoré(s).",
|
||||
"fpkg.run.what_folder": "un dossier de jeu",
|
||||
};
|
||||
|
||||
export default fr;
|
||||
@@ -4003,6 +4003,19 @@ c2c_test_deep_bad: "4 KiB का परीक्षण नहीं पहुँ
|
||||
"joberr.ava1_taken_over": "दूसरे डिवाइस ने यह भेजना अपने हाथ में ले लिया है; यह वहाँ जारी रहेगा।",
|
||||
"joberr.ava1_shared_jobs_unsupported": "दूसरा डिवाइस अभी भी यह भेजना चला रहा है। चलते समय इसे अपने हाथ में लेने (या देखने) के लिए दोनों कंसोल पर नया हेल्पर चाहिए: उन्हें अपडेट करें, या पहले उस डिवाइस पर भेजना रोकें।",
|
||||
"joberr.ava1_cannot_watch": "यह भेजना दूसरे डिवाइस से होकर जाता है, इसलिए इसे अपने हाथ में लिया जा सकता है पर देखा नहीं जा सकता।",
|
||||
"fpkg.make.folder_title": "एक गेम फ़ोल्डर",
|
||||
"fpkg.make.folder_body": "गेम की फ़ाइलें इस कंप्यूटर पर एक सामान्य फ़ोल्डर के रूप में, किसी गेम इमेज या डिबग कुंजी से बने पैकेज से। इसे किसी भी फ़ोल्डर की तरह संपादित करें, रखें या अपलोड करें।",
|
||||
"fpkg.folder.what": "फ़ोल्डर का नाम इमेज या पैकेज के नाम पर रखा जाता है, आउटपुट फ़ोल्डर में। कुछ भी संपीड़ित नहीं होता और PS5 तक कुछ नहीं भेजा जाता।",
|
||||
"cq_ext_folder": "फ़ोल्डर",
|
||||
"fpkg.stage.write_folder": "फ़ोल्डर लिखें",
|
||||
"fpkg.stage.verify_folder": "फ़ोल्डर दोबारा पढ़ें",
|
||||
"fpkg.folder.make": "गेम फ़ोल्डर बनाएँ",
|
||||
"fpkg.folder.already": "यह पहले से ही एक फ़ोल्डर है",
|
||||
"fpkg.folder.sourceOnly": "केवल गेम इमेज या डिबग कुंजी से बना पैकेज ही फ़ोल्डर बन सकता है; पहले आर्काइव को अनपैक करें।",
|
||||
"fpkg.folder.done": "गेम फ़ोल्डर लिखा गया और दोबारा पढ़कर जाँचा गया।",
|
||||
"cq_then_keep_folder": "फ़ोल्डर रखें",
|
||||
"cq_folder_sources_only": "केवल गेम इमेज या डिबग कुंजी से बना पैकेज ही फ़ोल्डर बन सकता है: {count} छोड़े गए।",
|
||||
"fpkg.run.what_folder": "एक गेम फ़ोल्डर",
|
||||
};
|
||||
|
||||
export default hi;
|
||||
@@ -4302,6 +4302,19 @@ c2c_test_deep_bad: "A 4 KiB-os tesztküldés nem érkezett meg: {detail}",
|
||||
"joberr.ava1_taken_over": "Egy másik eszköz átvette ezt a küldést; ott folytatódik.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Egy másik eszköz még futtatja ezt a küldést. Futás közbeni átvételéhez (vagy figyeléséhez) mindkét konzolon az újabb segéd kell: frissítsd őket, vagy előbb szüneteltesd a küldést azon az eszközön.",
|
||||
"joberr.ava1_cannot_watch": "Ez a küldés a másik eszközön át megy, ezért átvehető, de nem figyelhető.",
|
||||
"fpkg.make.folder_title": "Egy játékmappa",
|
||||
"fpkg.make.folder_body": "A játék fájljai egyszerű mappaként ezen a számítógépen, játékképfájlból vagy a debug kulccsal készült csomagból. Szerkesztheted, megtarthatod vagy feltöltheted, mint bármely mappát.",
|
||||
"fpkg.folder.what": "A mappa a képfájl vagy a csomag nevét kapja, és a kimeneti mappába kerül. Semmi sincs tömörítve, és semmi sem jut el a PS5-re.",
|
||||
"cq_ext_folder": "mappa",
|
||||
"fpkg.stage.write_folder": "Mappa írása",
|
||||
"fpkg.stage.verify_folder": "Mappa visszaolvasása",
|
||||
"fpkg.folder.make": "Játékmappa készítése",
|
||||
"fpkg.folder.already": "Ez már egy mappa",
|
||||
"fpkg.folder.sourceOnly": "Csak játékképfájlból vagy a debug kulccsal készült csomagból lehet mappa; előbb csomagold ki az archívumot.",
|
||||
"fpkg.folder.done": "A játékmappa megírva és visszaolvasva.",
|
||||
"cq_then_keep_folder": "Mappa megtartása",
|
||||
"cq_folder_sources_only": "Csak játékképfájlból vagy a debug kulccsal készült csomagból lehet mappa: {count} kihagyva.",
|
||||
"fpkg.run.what_folder": "egy játékmappát",
|
||||
};
|
||||
|
||||
export default hu;
|
||||
@@ -3996,6 +3996,19 @@ c2c_test_deep_bad: "Kiriman uji 4 KiB tidak tiba: {detail}",
|
||||
"joberr.ava1_taken_over": "Perangkat lain mengambil alih kiriman ini; kiriman berlanjut di sana.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Perangkat lain masih menjalankan kiriman ini. Mengambil alih (atau memantaunya) saat berjalan memerlukan helper terbaru di kedua konsol: perbarui, atau jeda dulu kiriman di perangkat itu.",
|
||||
"joberr.ava1_cannot_watch": "Kiriman ini lewat perangkat lain, jadi bisa diambil alih tetapi tidak bisa dipantau.",
|
||||
"fpkg.make.folder_title": "Folder game",
|
||||
"fpkg.make.folder_body": "File game sebagai folder biasa di komputer ini, dari image game atau paket yang dibuat dengan kunci debug. Edit, simpan, atau unggah seperti folder lainnya.",
|
||||
"fpkg.folder.what": "Folder diberi nama sesuai image atau paket, di folder output. Tidak ada yang dikompresi, dan tidak ada yang dikirim ke PS5.",
|
||||
"cq_ext_folder": "folder",
|
||||
"fpkg.stage.write_folder": "Tulis folder",
|
||||
"fpkg.stage.verify_folder": "Baca ulang folder",
|
||||
"fpkg.folder.make": "Buat folder game",
|
||||
"fpkg.folder.already": "Ini sudah berupa folder",
|
||||
"fpkg.folder.sourceOnly": "Hanya image game atau paket yang dibuat dengan kunci debug yang bisa dijadikan folder; ekstrak arsip terlebih dahulu.",
|
||||
"fpkg.folder.done": "Folder game ditulis dan dibaca ulang.",
|
||||
"cq_then_keep_folder": "Simpan foldernya",
|
||||
"cq_folder_sources_only": "Hanya image game atau paket yang dibuat dengan kunci debug yang bisa dijadikan folder: {count} dilewati.",
|
||||
"fpkg.run.what_folder": "folder game",
|
||||
};
|
||||
|
||||
export default id;
|
||||
@@ -3937,6 +3937,19 @@ c2c_test_deep_bad: "L’invio di prova di 4 KiB non è arrivato: {detail}",
|
||||
"joberr.ava1_taken_over": "Un altro dispositivo ha preso il controllo di questo invio; continua lì.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Un altro dispositivo sta ancora eseguendo questo invio. Prenderne il controllo (o guardarlo) mentre è in corso richiede l’assistente più recente su entrambe le console: aggiornali, oppure metti prima in pausa l’invio su quel dispositivo.",
|
||||
"joberr.ava1_cannot_watch": "Questo invio passa dall’altro dispositivo, quindi se ne può prendere il controllo ma non guardarlo.",
|
||||
"fpkg.make.folder_title": "Una cartella di gioco",
|
||||
"fpkg.make.folder_body": "I file del gioco come una normale cartella su questo computer, da un'immagine di gioco o da un pacchetto creato con la chiave di debug. Modificala, conservala o caricala come qualsiasi cartella.",
|
||||
"fpkg.folder.what": "La cartella prende il nome dall'immagine o dal pacchetto e viene creata nella cartella di destinazione. Nulla viene compresso e nulla arriva alla PS5.",
|
||||
"cq_ext_folder": "cartella",
|
||||
"fpkg.stage.write_folder": "Scrivi cartella",
|
||||
"fpkg.stage.verify_folder": "Rileggi la cartella",
|
||||
"fpkg.folder.make": "Crea cartella del gioco",
|
||||
"fpkg.folder.already": "Questa è già una cartella",
|
||||
"fpkg.folder.sourceOnly": "Solo un'immagine di gioco o un pacchetto creato con la chiave di debug può diventare una cartella; estrai prima l'archivio.",
|
||||
"fpkg.folder.done": "Cartella del gioco scritta e riletta.",
|
||||
"cq_then_keep_folder": "Conserva la cartella",
|
||||
"cq_folder_sources_only": "Solo un'immagine di gioco o un pacchetto creato con la chiave di debug può diventare una cartella: {count} saltati.",
|
||||
"fpkg.run.what_folder": "una cartella di gioco",
|
||||
};
|
||||
|
||||
export default it;
|
||||
@@ -4008,6 +4008,19 @@ c2c_test_deep_bad: "4 KiB のテスト送信が届きませんでした: {detail
|
||||
"joberr.ava1_taken_over": "別のデバイスがこの送信を引き継ぎました。そちらで続行します。",
|
||||
"joberr.ava1_shared_jobs_unsupported": "別のデバイスがまだこの送信を実行しています。実行中に引き継ぐ(または見守る)には両方のコンソールに新しいヘルパーが必要です: 更新するか、先にそのデバイスで送信を一時停止してください。",
|
||||
"joberr.ava1_cannot_watch": "この送信は別のデバイスを経由しているため、引き継ぐことはできますが見守ることはできません。",
|
||||
"fpkg.make.folder_title": "ゲームフォルダー",
|
||||
"fpkg.make.folder_body": "ゲームのファイルを、このコンピューター上の普通のフォルダーとして取り出します。元はゲームイメージか、デバッグキーで作ったパッケージです。編集・保管したり、普通のフォルダーとしてアップロードしたりできます。",
|
||||
"fpkg.folder.what": "フォルダーはイメージまたはパッケージの名前で出力フォルダーに作られます。圧縮はせず、PS5 には何も送りません。",
|
||||
"cq_ext_folder": "フォルダー",
|
||||
"fpkg.stage.write_folder": "フォルダーを書き込み",
|
||||
"fpkg.stage.verify_folder": "フォルダーを読み戻し",
|
||||
"fpkg.folder.make": "ゲームフォルダーを作成",
|
||||
"fpkg.folder.already": "これはすでにフォルダーです",
|
||||
"fpkg.folder.sourceOnly": "フォルダーにできるのは、ゲームイメージかデバッグキーで作ったパッケージだけです。アーカイブは先に展開してください。",
|
||||
"fpkg.folder.done": "ゲームフォルダーを書き込み、読み戻して確認しました。",
|
||||
"cq_then_keep_folder": "フォルダーを残す",
|
||||
"cq_folder_sources_only": "フォルダーにできるのは、ゲームイメージかデバッグキーで作ったパッケージだけです:{count} 件をスキップしました。",
|
||||
"fpkg.run.what_folder": "ゲームフォルダー",
|
||||
};
|
||||
|
||||
export default ja;
|
||||
@@ -4006,6 +4006,19 @@ c2c_test_deep_bad: "4 KiB 테스트 전송이 도착하지 않았습니다: {det
|
||||
"joberr.ava1_taken_over": "다른 기기가 이 전송을 넘겨받았습니다. 그곳에서 계속됩니다.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "다른 기기가 아직 이 전송을 실행 중입니다. 실행 중에 넘겨받거나 지켜보려면 두 콘솔 모두에 새 도우미가 필요합니다: 업데이트하거나 먼저 그 기기에서 전송을 일시 정지하세요.",
|
||||
"joberr.ava1_cannot_watch": "이 전송은 다른 기기를 거쳐 가므로 넘겨받을 수는 있지만 지켜볼 수는 없습니다.",
|
||||
"fpkg.make.folder_title": "게임 폴더",
|
||||
"fpkg.make.folder_body": "게임 이미지나 디버그 키로 만든 패키지에서 게임 파일을 이 컴퓨터의 일반 폴더로 꺼냅니다. 다른 폴더처럼 편집하거나 보관하거나 업로드할 수 있습니다.",
|
||||
"fpkg.folder.what": "폴더는 이미지나 패키지 이름으로 출력 폴더에 만들어집니다. 압축하지 않으며 PS5로는 아무것도 보내지 않습니다.",
|
||||
"cq_ext_folder": "폴더",
|
||||
"fpkg.stage.write_folder": "폴더 쓰기",
|
||||
"fpkg.stage.verify_folder": "폴더 다시 읽기",
|
||||
"fpkg.folder.make": "게임 폴더 만들기",
|
||||
"fpkg.folder.already": "이미 폴더입니다",
|
||||
"fpkg.folder.sourceOnly": "게임 이미지나 디버그 키로 만든 패키지만 폴더로 만들 수 있습니다. 압축 파일은 먼저 풀어 주세요.",
|
||||
"fpkg.folder.done": "게임 폴더를 쓰고 다시 읽어 확인했습니다.",
|
||||
"cq_then_keep_folder": "폴더 보관",
|
||||
"cq_folder_sources_only": "게임 이미지나 디버그 키로 만든 패키지만 폴더로 만들 수 있습니다: {count}개 건너뜀.",
|
||||
"fpkg.run.what_folder": "게임 폴더",
|
||||
};
|
||||
|
||||
export default ko;
|
||||
@@ -4242,6 +4242,19 @@ c2c_test_deep_bad: "Testowa wysyłka 4 KiB nie dotarła: {detail}",
|
||||
"joberr.ava1_taken_over": "Inne urządzenie przejęło tę wysyłkę; trwa ona tam.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Inne urządzenie nadal wykonuje tę wysyłkę. Przejęcie jej (lub obserwowanie) w trakcie wymaga nowszego pomocnika na obu konsolach: zaktualizuj je albo najpierw wstrzymaj wysyłkę na tamtym urządzeniu.",
|
||||
"joberr.ava1_cannot_watch": "Ta wysyłka idzie przez inne urządzenie, więc można ją przejąć, ale nie obserwować.",
|
||||
"fpkg.make.folder_title": "Folder gry",
|
||||
"fpkg.make.folder_body": "Pliki gry jako zwykły folder na tym komputerze, z obrazu gry lub pakietu zbudowanego kluczem debugowym. Edytuj go, zachowaj albo wyślij jak każdy folder.",
|
||||
"fpkg.folder.what": "Folder otrzymuje nazwę obrazu lub pakietu i trafia do folderu wyjściowego. Nic nie jest kompresowane i nic nie trafia na PS5.",
|
||||
"cq_ext_folder": "folder",
|
||||
"fpkg.stage.write_folder": "Zapis folderu",
|
||||
"fpkg.stage.verify_folder": "Odczyt kontrolny folderu",
|
||||
"fpkg.folder.make": "Utwórz folder gry",
|
||||
"fpkg.folder.already": "To już jest folder",
|
||||
"fpkg.folder.sourceOnly": "Folderem może stać się tylko obraz gry lub pakiet zbudowany kluczem debugowym; najpierw rozpakuj archiwum.",
|
||||
"fpkg.folder.done": "Folder gry zapisany i odczytany kontrolnie.",
|
||||
"cq_then_keep_folder": "Zachowaj folder",
|
||||
"cq_folder_sources_only": "Folderem może stać się tylko obraz gry lub pakiet zbudowany kluczem debugowym: pominięto {count}.",
|
||||
"fpkg.run.what_folder": "folder gry",
|
||||
};
|
||||
|
||||
export default pl;
|
||||
@@ -4004,6 +4004,19 @@ c2c_test_deep_bad: "O envio de teste de 4 KiB não chegou: {detail}",
|
||||
"joberr.ava1_taken_over": "Outro dispositivo assumiu este envio; ele continua lá.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Outro dispositivo ainda está executando este envio. Assumi-lo (ou acompanhá-lo) enquanto roda exige o auxiliar mais novo nos dois consoles: atualize-os ou pause o envio primeiro naquele dispositivo.",
|
||||
"joberr.ava1_cannot_watch": "Este envio passa pelo outro dispositivo, então pode ser assumido, mas não acompanhado.",
|
||||
"fpkg.make.folder_title": "Uma pasta de jogo",
|
||||
"fpkg.make.folder_body": "Os arquivos do jogo como uma pasta comum neste computador, a partir de uma imagem de jogo ou de um pacote feito com a chave de depuração. Edite, guarde ou envie como qualquer pasta.",
|
||||
"fpkg.folder.what": "A pasta recebe o nome da imagem ou do pacote, na pasta de saída. Nada é comprimido e nada vai para o PS5.",
|
||||
"cq_ext_folder": "pasta",
|
||||
"fpkg.stage.write_folder": "Gravar pasta",
|
||||
"fpkg.stage.verify_folder": "Reler a pasta",
|
||||
"fpkg.folder.make": "Criar pasta do jogo",
|
||||
"fpkg.folder.already": "Isto já é uma pasta",
|
||||
"fpkg.folder.sourceOnly": "Só uma imagem de jogo ou um pacote feito com a chave de depuração pode virar pasta; extraia o arquivo compactado antes.",
|
||||
"fpkg.folder.done": "Pasta do jogo gravada e relida.",
|
||||
"cq_then_keep_folder": "Manter a pasta",
|
||||
"cq_folder_sources_only": "Só uma imagem de jogo ou um pacote feito com a chave de depuração pode virar pasta: {count} ignorados.",
|
||||
"fpkg.run.what_folder": "uma pasta de jogo",
|
||||
};
|
||||
|
||||
export default pt_BR;
|
||||
@@ -3937,6 +3937,19 @@ c2c_test_deep_bad: "Тестовая отправка 4 КиБ не дошла:
|
||||
"joberr.ava1_taken_over": "Другое устройство перехватило эту отправку; она продолжается там.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Другое устройство всё ещё выполняет эту отправку. Чтобы перехватить её (или наблюдать) во время работы, на обеих консолях нужен новый помощник: обновите их или сначала приостановите отправку на том устройстве.",
|
||||
"joberr.ava1_cannot_watch": "Эта отправка идёт через другое устройство, поэтому её можно перехватить, но не наблюдать.",
|
||||
"fpkg.make.folder_title": "Папка игры",
|
||||
"fpkg.make.folder_body": "Файлы игры обычной папкой на этом компьютере — из образа игры или пакета, собранного с отладочным ключом. Редактируйте её, храните или загружайте как любую папку.",
|
||||
"fpkg.folder.what": "Папка получает имя образа или пакета и создаётся в папке вывода. Ничего не сжимается, и ничего не отправляется на PS5.",
|
||||
"cq_ext_folder": "папка",
|
||||
"fpkg.stage.write_folder": "Запись папки",
|
||||
"fpkg.stage.verify_folder": "Проверка папки чтением",
|
||||
"fpkg.folder.make": "Создать папку игры",
|
||||
"fpkg.folder.already": "Это уже папка",
|
||||
"fpkg.folder.sourceOnly": "Папкой может стать только образ игры или пакет, собранный с отладочным ключом; сначала распакуйте архив.",
|
||||
"fpkg.folder.done": "Папка игры записана и проверена чтением.",
|
||||
"cq_then_keep_folder": "Оставить папку",
|
||||
"cq_folder_sources_only": "Папкой может стать только образ игры или пакет, собранный с отладочным ключом: пропущено {count}.",
|
||||
"fpkg.run.what_folder": "папку игры",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
@@ -3996,6 +3996,19 @@ c2c_test_deep_bad: "การส่งทดสอบ 4 KiB ไปไม่ถ
|
||||
"joberr.ava1_taken_over": "อุปกรณ์อื่นรับช่วงการส่งนี้ไปแล้ว และจะทำต่อที่นั่น",
|
||||
"joberr.ava1_shared_jobs_unsupported": "อุปกรณ์อื่นยังทำการส่งนี้อยู่ การรับช่วง (หรือดู) ขณะทำงานต้องใช้ตัวช่วยเวอร์ชันใหม่บนทั้งสองคอนโซล: อัปเดต หรือหยุดการส่งชั่วคราวบนอุปกรณ์นั้นก่อน",
|
||||
"joberr.ava1_cannot_watch": "การส่งนี้วิ่งผ่านอุปกรณ์อื่น จึงรับช่วงต่อได้แต่ดูไม่ได้",
|
||||
"fpkg.make.folder_title": "โฟลเดอร์เกม",
|
||||
"fpkg.make.folder_body": "ไฟล์ของเกมในรูปโฟลเดอร์ธรรมดาบนคอมพิวเตอร์เครื่องนี้ จากอิมเมจเกมหรือแพ็กเกจที่สร้างด้วยคีย์ดีบัก แก้ไข เก็บไว้ หรืออัปโหลดได้เหมือนโฟลเดอร์ทั่วไป",
|
||||
"fpkg.folder.what": "โฟลเดอร์จะตั้งชื่อตามอิมเมจหรือแพ็กเกจ ในโฟลเดอร์ปลายทาง ไม่มีการบีบอัด และไม่มีอะไรถูกส่งไปที่ PS5",
|
||||
"cq_ext_folder": "โฟลเดอร์",
|
||||
"fpkg.stage.write_folder": "เขียนโฟลเดอร์",
|
||||
"fpkg.stage.verify_folder": "อ่านโฟลเดอร์กลับ",
|
||||
"fpkg.folder.make": "สร้างโฟลเดอร์เกม",
|
||||
"fpkg.folder.already": "นี่เป็นโฟลเดอร์อยู่แล้ว",
|
||||
"fpkg.folder.sourceOnly": "เฉพาะอิมเมจเกมหรือแพ็กเกจที่สร้างด้วยคีย์ดีบักเท่านั้นที่ทำเป็นโฟลเดอร์ได้ แตกไฟล์บีบอัดก่อน",
|
||||
"fpkg.folder.done": "เขียนโฟลเดอร์เกมและอ่านกลับตรวจสอบแล้ว",
|
||||
"cq_then_keep_folder": "เก็บโฟลเดอร์ไว้",
|
||||
"cq_folder_sources_only": "เฉพาะอิมเมจเกมหรือแพ็กเกจที่สร้างด้วยคีย์ดีบักเท่านั้นที่ทำเป็นโฟลเดอร์ได้: ข้าม {count} รายการ",
|
||||
"fpkg.run.what_folder": "โฟลเดอร์เกม",
|
||||
};
|
||||
|
||||
export default th;
|
||||
@@ -3994,6 +3994,19 @@ c2c_test_deep_bad: "4 KiB'lik test gönderimi ulaşmadı: {detail}",
|
||||
"joberr.ava1_taken_over": "Bu gönderimi başka bir cihaz devraldı; orada devam ediyor.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Bu gönderimi başka bir cihaz hâlâ çalıştırıyor. Çalışırken devralmak (veya izlemek) için her iki konsolda da yeni yardımcı gerekir: güncelleyin ya da önce gönderimi o cihazda duraklatın.",
|
||||
"joberr.ava1_cannot_watch": "Bu gönderim diğer cihaz üzerinden geçiyor, bu yüzden devralınabilir ama izlenemez.",
|
||||
"fpkg.make.folder_title": "Bir oyun klasörü",
|
||||
"fpkg.make.folder_body": "Oyunun dosyaları bu bilgisayarda düz bir klasör olarak; bir oyun kalıbından ya da hata ayıklama anahtarıyla yapılmış bir paketten. Düzenleyin, saklayın ya da her klasör gibi yükleyin.",
|
||||
"fpkg.folder.what": "Klasör, kalıbın veya paketin adını alır ve çıktı klasörüne yazılır. Hiçbir şey sıkıştırılmaz ve PS5'e hiçbir şey gitmez.",
|
||||
"cq_ext_folder": "klasör",
|
||||
"fpkg.stage.write_folder": "Klasörü yaz",
|
||||
"fpkg.stage.verify_folder": "Klasörü geri oku",
|
||||
"fpkg.folder.make": "Oyun klasörü oluştur",
|
||||
"fpkg.folder.already": "Bu zaten bir klasör",
|
||||
"fpkg.folder.sourceOnly": "Yalnızca bir oyun kalıbı ya da hata ayıklama anahtarıyla yapılmış bir paket klasöre dönüştürülebilir; önce arşivi açın.",
|
||||
"fpkg.folder.done": "Oyun klasörü yazıldı ve geri okundu.",
|
||||
"cq_then_keep_folder": "Klasörü sakla",
|
||||
"cq_folder_sources_only": "Yalnızca bir oyun kalıbı ya da hata ayıklama anahtarıyla yapılmış bir paket klasöre dönüştürülebilir: {count} atlandı.",
|
||||
"fpkg.run.what_folder": "bir oyun klasörü",
|
||||
};
|
||||
|
||||
export default tr;
|
||||
@@ -3989,6 +3989,19 @@ c2c_test_deep_bad: "Lượt gửi thử 4 KiB không tới: {detail}",
|
||||
"joberr.ava1_taken_over": "Một thiết bị khác đã tiếp quản lượt gửi này; nó tiếp tục ở đó.",
|
||||
"joberr.ava1_shared_jobs_unsupported": "Một thiết bị khác vẫn đang chạy lượt gửi này. Tiếp quản (hoặc theo dõi) khi nó đang chạy cần trình trợ giúp mới trên cả hai máy: hãy cập nhật, hoặc tạm dừng lượt gửi trên thiết bị đó trước.",
|
||||
"joberr.ava1_cannot_watch": "Lượt gửi này đi qua thiết bị khác, nên có thể tiếp quản nhưng không thể theo dõi.",
|
||||
"fpkg.make.folder_title": "Một thư mục game",
|
||||
"fpkg.make.folder_body": "Các tệp của game dưới dạng thư mục thường trên máy tính này, từ ảnh game hoặc gói được tạo bằng khóa debug. Chỉnh sửa, giữ lại hoặc tải lên như mọi thư mục.",
|
||||
"fpkg.folder.what": "Thư mục được đặt tên theo ảnh hoặc gói, trong thư mục đầu ra. Không có gì bị nén và không có gì được gửi tới PS5.",
|
||||
"cq_ext_folder": "thư mục",
|
||||
"fpkg.stage.write_folder": "Ghi thư mục",
|
||||
"fpkg.stage.verify_folder": "Đọc lại thư mục",
|
||||
"fpkg.folder.make": "Tạo thư mục game",
|
||||
"fpkg.folder.already": "Đây đã là một thư mục",
|
||||
"fpkg.folder.sourceOnly": "Chỉ ảnh game hoặc gói được tạo bằng khóa debug mới có thể thành thư mục; hãy giải nén tệp nén trước.",
|
||||
"fpkg.folder.done": "Đã ghi và đọc lại thư mục game.",
|
||||
"cq_then_keep_folder": "Giữ thư mục",
|
||||
"cq_folder_sources_only": "Chỉ ảnh game hoặc gói được tạo bằng khóa debug mới có thể thành thư mục: đã bỏ qua {count}.",
|
||||
"fpkg.run.what_folder": "một thư mục game",
|
||||
};
|
||||
|
||||
export default vi;
|
||||
@@ -3943,6 +3943,19 @@ c2c_test_deep_bad: "4 KiB 测试发送未到达:{detail}",
|
||||
"joberr.ava1_taken_over": "另一台设备已接管此发送,它会在那里继续。",
|
||||
"joberr.ava1_shared_jobs_unsupported": "另一台设备仍在运行此发送。在运行中接管(或查看)需要两台主机都使用新版助手:请更新助手,或先在那台设备上暂停发送。",
|
||||
"joberr.ava1_cannot_watch": "此发送经由另一台设备传输,因此可以接管,但无法查看。",
|
||||
"fpkg.make.folder_title": "游戏文件夹",
|
||||
"fpkg.make.folder_body": "把游戏镜像或用调试密钥制作的包里的游戏文件,还原成这台电脑上的普通文件夹。可以像任何文件夹一样编辑、保存或上传。",
|
||||
"fpkg.folder.what": "文件夹以镜像或包的名称命名,放在输出文件夹中。不做任何压缩,也不会向 PS5 发送任何内容。",
|
||||
"cq_ext_folder": "文件夹",
|
||||
"fpkg.stage.write_folder": "写入文件夹",
|
||||
"fpkg.stage.verify_folder": "回读文件夹",
|
||||
"fpkg.folder.make": "制作游戏文件夹",
|
||||
"fpkg.folder.already": "这已经是一个文件夹",
|
||||
"fpkg.folder.sourceOnly": "只有游戏镜像或用调试密钥制作的包才能还原成文件夹;请先解压压缩包。",
|
||||
"fpkg.folder.done": "游戏文件夹已写入并回读校验。",
|
||||
"cq_then_keep_folder": "保留文件夹",
|
||||
"cq_folder_sources_only": "只有游戏镜像或用调试密钥制作的包才能还原成文件夹:已跳过 {count} 个。",
|
||||
"fpkg.run.what_folder": "游戏文件夹",
|
||||
};
|
||||
|
||||
export default zh_CN;
|
||||
@@ -3937,6 +3937,19 @@ c2c_test_deep_bad: "4 KiB 測試傳送未抵達:{detail}",
|
||||
"joberr.ava1_taken_over": "另一部裝置已接手此傳送,它會在那裡繼續。",
|
||||
"joberr.ava1_shared_jobs_unsupported": "另一部裝置仍在執行此傳送。在執行中接手(或查看)需要兩部主機都使用新版協助程式:請更新協助程式,或先在那部裝置上暫停傳送。",
|
||||
"joberr.ava1_cannot_watch": "此傳送經由另一部裝置傳輸,因此可以接手,但無法查看。",
|
||||
"fpkg.make.folder_title": "遊戲資料夾",
|
||||
"fpkg.make.folder_body": "把遊戲映像或以除錯金鑰製作的套件中的遊戲檔案,還原成這台電腦上的一般資料夾。可以像任何資料夾一樣編輯、保留或上傳。",
|
||||
"fpkg.folder.what": "資料夾以映像或套件的名稱命名,放在輸出資料夾中。不做任何壓縮,也不會傳送任何內容到 PS5。",
|
||||
"cq_ext_folder": "資料夾",
|
||||
"fpkg.stage.write_folder": "寫入資料夾",
|
||||
"fpkg.stage.verify_folder": "回讀資料夾",
|
||||
"fpkg.folder.make": "製作遊戲資料夾",
|
||||
"fpkg.folder.already": "這已經是資料夾",
|
||||
"fpkg.folder.sourceOnly": "只有遊戲映像或以除錯金鑰製作的套件才能還原成資料夾;請先解壓縮封存檔。",
|
||||
"fpkg.folder.done": "遊戲資料夾已寫入並回讀驗證。",
|
||||
"cq_then_keep_folder": "保留資料夾",
|
||||
"cq_folder_sources_only": "只有遊戲映像或以除錯金鑰製作的套件才能還原成資料夾:已略過 {count} 個。",
|
||||
"fpkg.run.what_folder": "遊戲資料夾",
|
||||
};
|
||||
|
||||
export default zh_TW;
|
||||
@@ -795,6 +795,14 @@ export async function browserInvoke<T>(
|
||||
full_verify: args["fullVerify"] ?? false,
|
||||
});
|
||||
}
|
||||
case "folder_build": {
|
||||
return postJson<T>("/api/folder/build", {
|
||||
source: args["source"],
|
||||
output_dir: args["outputDir"],
|
||||
name: args["name"],
|
||||
full_verify: args["fullVerify"] ?? false,
|
||||
});
|
||||
}
|
||||
case "job_status": {
|
||||
// TS caller: { jobId } (Tauri 2 camelCases job_id → jobId)
|
||||
const jobId = args["jobId"] as string;
|
||||
|
||||
@@ -22,6 +22,15 @@ function whenIdle(): Promise<void> {
|
||||
async function build(item: ConvertItem) {
|
||||
await whenIdle();
|
||||
const conv = useFpkgConversion.getState();
|
||||
if (item.build?.kind === "folder") {
|
||||
// A plain folder from an image or package: kept here, nothing to hand on.
|
||||
await conv.buildFolder(item.source, item.outputDir);
|
||||
await whenIdle();
|
||||
const p = useFpkgConversion.getState().pipeline;
|
||||
if (p.phase === "done") return { ok: true, packagePath: p.packagePath, installed: true };
|
||||
if (p.phase === "failed") return { ok: false, message: p.message };
|
||||
return { ok: false, message: "The build did not start." };
|
||||
}
|
||||
if (item.build?.kind === "image") {
|
||||
// A game image, in the format picked; sent on to the Upload queue when asked.
|
||||
const send = item.then !== "keep" && !!item.host && !!item.imageDest;
|
||||
|
||||
@@ -105,7 +105,7 @@ export function GameCard(props: GameCardProps) {
|
||||
remote
|
||||
label={tr("fpkg.browseImage", undefined, "Image or archive…")}
|
||||
title={tr("fpkg.pickImage", undefined, "Choose a game image or archive")}
|
||||
filters={[{ name: "Game image or archive", extensions: ["exfat", "ffpkg", "ffpfs", "ffpfsc", "zip", "7z", "rar"] }]}
|
||||
filters={[{ name: "Game image, package or archive", extensions: ["exfat", "ffpkg", "ffpfs", "ffpfsc", "pkg", "zip", "7z", "rar"] }]}
|
||||
disabled={props.locked || props.checking}
|
||||
onMainClick={props.onBrowseImage}
|
||||
onPick={props.onRemotePick}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Package, HardDrive } from "lucide-react";
|
||||
import { Folder, Package, HardDrive } from "lucide-react";
|
||||
|
||||
import { Badge, Card } from "../../components";
|
||||
import { ps5FakeGameUnplayableFirmware } from "../../lib/ps5Firmware";
|
||||
import type { MakeKind } from "../../state/convertPrefs";
|
||||
import { useTr } from "../../state/lang";
|
||||
|
||||
/**
|
||||
* ② What to make: a package the PS5 installs, or a game image ShadowMount+ mounts. The two
|
||||
* are explained side by side, and the one that suits the game on the connected console is
|
||||
* marked: a PS5 game on firmware above 11.60 installs as a package but will not play, so the
|
||||
* image is the way; otherwise the package is the ordinary choice.
|
||||
* ② What to make: a package the PS5 installs, a game image ShadowMount+ mounts, or (#469) the
|
||||
* game's files back as a plain folder. They are explained side by side, and the one of the
|
||||
* first two that suits the game on the connected console is marked: a PS5 game on firmware
|
||||
* above 11.60 installs as a package but will not play, so the image is the way; otherwise the
|
||||
* package is the ordinary choice. The folder is never marked: it is a way back, not a way in.
|
||||
*/
|
||||
export function MakeCard({
|
||||
make,
|
||||
@@ -18,8 +20,8 @@ export function MakeCard({
|
||||
checked,
|
||||
locked = false,
|
||||
}: {
|
||||
make: "pkg" | "image";
|
||||
onMake: (make: "pkg" | "image") => void;
|
||||
make: MakeKind;
|
||||
onMake: (make: MakeKind) => void;
|
||||
/** The connected console's kernel string, for the firmware line. */
|
||||
kernel: string | null | undefined;
|
||||
/** The checked game's content id (or title id); null before a game is checked. */
|
||||
@@ -31,7 +33,7 @@ export function MakeCard({
|
||||
const tr = useTr();
|
||||
const unplayableOn = checked ? ps5FakeGameUnplayableFirmware(kernel, contentId) : null;
|
||||
const recommended: "pkg" | "image" | null = !checked ? null : unplayableOn ? "image" : "pkg";
|
||||
const tiles: { id: "pkg" | "image"; icon: typeof Package; title: string; body: string }[] = [
|
||||
const tiles: { id: MakeKind; icon: typeof Package; title: string; body: string }[] = [
|
||||
{
|
||||
id: "pkg",
|
||||
icon: Package,
|
||||
@@ -52,12 +54,22 @@ export function MakeCard({
|
||||
"One file on a PS5 drive that ShadowMount+ mounts and shows on the home screen. Nothing is installed, and it plays on firmware where a PS5 package will not.",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "folder",
|
||||
icon: Folder,
|
||||
title: tr("fpkg.make.folder_title", undefined, "A game folder"),
|
||||
body: tr(
|
||||
"fpkg.make.folder_body",
|
||||
undefined,
|
||||
"The game's files as a plain folder on this computer, from a game image or a package made with the debug key. Edit it, keep it, or upload it like any folder.",
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="font-medium">{tr("fpkg.card.make", undefined, "② What to make")}</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2" role="radiogroup" data-testid="make-choice">
|
||||
<div className="grid gap-2 sm:grid-cols-3" role="radiogroup" data-testid="make-choice">
|
||||
{tiles.map((t) => {
|
||||
const on = make === t.id;
|
||||
const Icon = t.icon;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { FpkgCompression, FpkgEstimate, ImageFormat } from "../../api/fpkg";
|
||||
import { Button, Card } from "../../components";
|
||||
import { PLAYGO_LANGUAGES } from "../../lib/playgoLanguages";
|
||||
import type { MakeKind } from "../../state/convertPrefs";
|
||||
import { useTr } from "../../state/lang";
|
||||
import { CompressionTiles } from "./CompressionTiles";
|
||||
import { FullVerifyOption, ImageChoice, type Lz4Choice } from "./ImageOptions";
|
||||
@@ -32,7 +33,7 @@ export interface OptionsCardProps {
|
||||
language?: string;
|
||||
/** What Convert makes. Compression and language are a package's: an image carries the
|
||||
* folder's files as they are, and its own compression is chosen beside its format. */
|
||||
make?: "pkg" | "image";
|
||||
make?: MakeKind;
|
||||
/** The image's options, shown in place of the package's when `make` is "image". */
|
||||
image?: {
|
||||
format: ImageFormat;
|
||||
@@ -107,6 +108,14 @@ export function OptionsCard(props: OptionsCardProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : props.make === "folder" ? (
|
||||
<div className="text-xs text-[var(--color-muted)]" data-testid="options-folder">
|
||||
{tr(
|
||||
"fpkg.folder.what",
|
||||
undefined,
|
||||
"The folder is named after the image or package, in the output folder. Nothing is compressed, and nothing reaches the PS5.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-[var(--color-muted)]">
|
||||
@@ -120,7 +129,7 @@ export function OptionsCard(props: OptionsCardProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.onLanguage && props.make !== "image" && (
|
||||
{props.onLanguage && props.make !== "image" && props.make !== "folder" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="fpkg-language" className="text-[var(--color-muted)]">
|
||||
{tr("fpkg.language", undefined, "Game language")}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function QueueCard(p: QueueCardProps) {
|
||||
className="shrink-0 rounded bg-[var(--color-surface-3)] px-1.5 py-px font-mono text-[10px] text-[var(--color-muted)]"
|
||||
data-testid="cq-ext"
|
||||
>
|
||||
{buildExtension(i.build)}
|
||||
{i.build?.kind === "folder" ? tr("cq_ext_folder", undefined, "folder") : buildExtension(i.build)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -208,6 +208,62 @@ describe("RunCard", () => {
|
||||
// Queueing still works: a folder of games can be queued as images.
|
||||
expect(button(out, "Add this game to the queue")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("a folder is only kept, and a folder source cannot become one", () => {
|
||||
const ok = idle({ make: "folder" }, { canFolder: true });
|
||||
expect(options(ok, "After converting")).toEqual(["Keep the folder"]);
|
||||
expect(button(ok, "Make game folder")?.disabled).toBe(false);
|
||||
expect(ok).not.toContain("This is already a folder");
|
||||
const already = idle({ make: "folder" }, { canFolder: false, isFolderSource: true });
|
||||
expect(button(already, "Make game folder")?.disabled).toBe(true);
|
||||
expect(already).toContain("This is already a folder");
|
||||
const archive = idle({ make: "folder" }, { canFolder: false });
|
||||
expect(button(archive, "Make game folder")?.disabled).toBe(true);
|
||||
expect(archive).toContain("Only a game image or a package made with the debug key can become a folder");
|
||||
// Nothing is sent anywhere, so no console is asked for.
|
||||
expect(idle({ make: "folder" }, { canFolder: true, canInstall: false })).not.toContain("Connect to a PS5");
|
||||
});
|
||||
});
|
||||
|
||||
it("a finished folder shows its path and Show in folder, nothing to install or delete", () => {
|
||||
const out = html(
|
||||
{ ...done, mode: "folder", host: null, packagePath: "/out/Game", titleId: null, installMs: 0 },
|
||||
{ sourceBytes: 1024 },
|
||||
);
|
||||
expect(out).toContain("Game folder written and read back.");
|
||||
expect(out).toContain("/out/Game");
|
||||
expect(button(out, "Show in folder")).not.toBeNull();
|
||||
expect(button(out, "Stream & install")).toBeNull();
|
||||
expect(button(out, "Delete package")).toBeNull();
|
||||
expect(button(out, "View details")).toBeNull();
|
||||
expect(button(out, "Upload to PS5")).toBeNull();
|
||||
expect(out).not.toContain("% of");
|
||||
});
|
||||
|
||||
it("names a folder build's stages for a folder", () => {
|
||||
const out = html({
|
||||
phase: "running",
|
||||
mode: "folder",
|
||||
source: "/g/Game.exfat",
|
||||
host: null,
|
||||
stage: "write",
|
||||
stageDone: 1,
|
||||
stageTotal: 2,
|
||||
startedMs: 0,
|
||||
stageStartedMs: 0,
|
||||
stageMs: {},
|
||||
jobId: "j",
|
||||
installTaskId: null,
|
||||
taskId: null,
|
||||
packagePath: null,
|
||||
titleId: null,
|
||||
copiedSource: null,
|
||||
extractedSource: null,
|
||||
});
|
||||
expect(out).toContain("Write folder");
|
||||
expect(out).toContain("Read the folder back");
|
||||
expect(out).not.toContain("Write package");
|
||||
expect(button(out, "Cancel")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("lists the stages while running, with Cancel during the build", () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import { CheckCircle2, Circle, ListPlus, Loader2, XCircle } from "lucide-react";
|
||||
|
||||
import { Button, Card, Checkbox, ProgressBar, Toggle } from "../../components";
|
||||
import type { ConvertThen } from "../../state/convertQueue";
|
||||
import { makesImage } from "../../state/fpkgConversion";
|
||||
import type { MakeKind } from "../../state/convertPrefs";
|
||||
import { makesFolder, makesImage } from "../../state/fpkgConversion";
|
||||
import type { InstallMethod, Pipeline, PipelineStage } from "../../state/fpkgConversion";
|
||||
import { useTr } from "../../state/lang";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
@@ -76,6 +77,10 @@ export interface RunCardProps {
|
||||
onRun: () => void;
|
||||
/** The source is a game folder on this computer, so it can become an image here. */
|
||||
canImage?: boolean;
|
||||
/** The source is a game image or a package, so it can become a plain folder. */
|
||||
canFolder?: boolean;
|
||||
/** The source is a folder already: there is nothing to unpack. */
|
||||
isFolderSource?: boolean;
|
||||
/** One line saying what the run makes, of which game, where (from ① to ③). */
|
||||
summary?: string;
|
||||
/** Add the current game to the queue, with the same choice. */
|
||||
@@ -136,8 +141,13 @@ export function RunCard(props: RunCardProps) {
|
||||
const rows = stageRows(p, installTask);
|
||||
// An image build plans, writes and verifies an image, not a package.
|
||||
const image = p.phase !== "idle" && makesImage(p.mode);
|
||||
const folder = p.phase !== "idle" && makesFolder(p.mode);
|
||||
const label = (s: PipelineStage) =>
|
||||
image && s === "plan"
|
||||
folder && s === "write"
|
||||
? tr("fpkg.stage.write_folder", undefined, "Write folder")
|
||||
: folder && s === "verify"
|
||||
? tr("fpkg.stage.verify_folder", undefined, "Read the folder back")
|
||||
: image && s === "plan"
|
||||
? tr("fpkg.stage.plan_image", undefined, "Plan image")
|
||||
: image && s === "write"
|
||||
? tr("fpkg.stage.write_image", undefined, "Write image")
|
||||
@@ -173,14 +183,18 @@ export function RunCard(props: RunCardProps) {
|
||||
if (p.phase === "idle") {
|
||||
const c = props.choice;
|
||||
const image = c.make === "image";
|
||||
const folder = c.make === "folder";
|
||||
const packing = image && lz4Active(c.imageFormat, c.lz4);
|
||||
const sends = c.then !== "keep";
|
||||
const canRun = props.canConvert !== false && (!image || !!props.canImage);
|
||||
const runLabel = image
|
||||
? c.imageCompress && !packing
|
||||
const canRun =
|
||||
props.canConvert !== false && (folder ? !!props.canFolder : !image || !!props.canImage);
|
||||
const runLabel = folder
|
||||
? tr("fpkg.folder.make", undefined, "Make game folder")
|
||||
: image
|
||||
? c.imageCompress && !packing
|
||||
? tr("fpkg.image.make_compressed", undefined, "Make compressed image")
|
||||
: tr("fpkg.image.make", undefined, "Make game image")
|
||||
: !sends
|
||||
: tr("fpkg.image.make", undefined, "Make game image")
|
||||
: !sends
|
||||
? tr("fpkg.convertOnly", undefined, "Convert only")
|
||||
: props.replaces
|
||||
? tr("fpkg.convertReplace", undefined, "Convert & replace")
|
||||
@@ -219,6 +233,20 @@ export function RunCard(props: RunCardProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{folder && props.isFolderSource && (
|
||||
<div className="text-xs text-[var(--color-muted)]" data-testid="folder-already">
|
||||
{tr("fpkg.folder.already", undefined, "This is already a folder")}
|
||||
</div>
|
||||
)}
|
||||
{folder && !props.isFolderSource && !props.canFolder && props.canConvert !== false && (
|
||||
<div className="text-xs text-[var(--color-muted)]">
|
||||
{tr(
|
||||
"fpkg.folder.sourceOnly",
|
||||
undefined,
|
||||
"Only a game image or a package made with the debug key can become a folder; unpack an archive first.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.replaces && props.canInstall && !image && sends && (
|
||||
<div className="text-xs text-[var(--color-muted)]">
|
||||
{tr(
|
||||
@@ -228,7 +256,7 @@ export function RunCard(props: RunCardProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!props.canInstall && (
|
||||
{!props.canInstall && !folder && (
|
||||
<div className="text-xs text-[var(--color-muted)]">
|
||||
{tr("fpkg.needConsole", undefined, "Connect to a PS5 to install.")}
|
||||
</div>
|
||||
@@ -375,11 +403,13 @@ export function RunCard(props: RunCardProps) {
|
||||
undefined,
|
||||
"Game image written and read back. Copy it to a PS5 drive (Upload) and ShadowMount+ will mount it.",
|
||||
)
|
||||
: tr("fpkg.done", undefined, "Package written")}
|
||||
: folder
|
||||
? tr("fpkg.folder.done", undefined, "Game folder written and read back.")
|
||||
: tr("fpkg.done", undefined, "Package written")}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-muted)]">
|
||||
{p.packageBytes > 0 && <span>{prettyBytes(p.packageBytes)}</span>}
|
||||
{p.packageBytes > 0 && props.sourceBytes ? (
|
||||
{p.packageBytes > 0 && props.sourceBytes && !folder ? (
|
||||
<span>
|
||||
{" "}
|
||||
{tr(
|
||||
@@ -427,7 +457,7 @@ export function RunCard(props: RunCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!p.deleted && !makesImage(p.mode) && installChoice(!installed)}
|
||||
{!p.deleted && !makesImage(p.mode) && !folder && installChoice(!installed)}
|
||||
{!p.deleted && makesImage(p.mode) && (
|
||||
<ImageUpload
|
||||
queued={!!p.uploadQueued}
|
||||
@@ -445,10 +475,10 @@ export function RunCard(props: RunCardProps) {
|
||||
{!p.deleted && props.onShowFolder && (
|
||||
<Button onClick={props.onShowFolder}>{tr("fpkg.showFolder", undefined, "Show in folder")}</Button>
|
||||
)}
|
||||
{!p.deleted && !makesImage(p.mode) && props.onViewPackage && (
|
||||
{!p.deleted && !makesImage(p.mode) && !folder && props.onViewPackage && (
|
||||
<Button onClick={props.onViewPackage}>{tr("viewer_open", undefined, "View details")}</Button>
|
||||
)}
|
||||
{!p.deleted && !makesImage(p.mode) && (
|
||||
{!p.deleted && !makesImage(p.mode) && !folder && (
|
||||
<Button variant="danger" onClick={props.onDelete}>
|
||||
{props.deleteArmed
|
||||
? tr("fpkg.confirmDelete", undefined, deleteLabel(true))
|
||||
@@ -525,8 +555,8 @@ function ImageUpload({
|
||||
* the queue alike. What to make is chosen under ② and the image's own options under ③;
|
||||
* this card asks what happens after, and runs. */
|
||||
export interface OutputChoiceProps {
|
||||
make: "pkg" | "image";
|
||||
onMake: (make: "pkg" | "image") => void;
|
||||
make: MakeKind;
|
||||
onMake: (make: MakeKind) => void;
|
||||
/** The image picked (format, compressed into a .ffpfsc). */
|
||||
imageFormat: ImageFormat;
|
||||
imageCompress: boolean;
|
||||
@@ -556,7 +586,9 @@ function OutputChoice(c: OutputChoiceProps) {
|
||||
className="select w-auto!"
|
||||
data-testid="convert-then"
|
||||
>
|
||||
{image ? (
|
||||
{c.make === "folder" ? (
|
||||
<option value="keep">{tr("cq_then_keep_folder", undefined, "Keep the folder")}</option>
|
||||
) : image ? (
|
||||
<>
|
||||
<option value="keep">{tr("cq_then_keep_image", undefined, "Keep the image")}</option>
|
||||
<option value="upload" disabled={!c.canInstall}>
|
||||
|
||||
@@ -78,6 +78,44 @@ describe("MakeCard", () => {
|
||||
expect(none).not.toContain("Recommended");
|
||||
expect(none).toContain("Check a game under");
|
||||
});
|
||||
|
||||
it("offers a game folder as a third choice, never marked as recommended", () => {
|
||||
const out = renderToStaticMarkup(
|
||||
<MakeCard make="folder" onMake={() => {}} kernel="r229358/releases/9.60" contentId="UP9000-PPSA03671_00-X" checked />,
|
||||
);
|
||||
expect((out.match(/role="radio"/g) ?? []).length).toBe(3);
|
||||
expect(out).toContain("A game folder");
|
||||
expect(out).toContain("from a game image or a package made with the debug key");
|
||||
expect(out).toMatch(/aria-checked="true"[^>]*>[\s\S]*?A game folder/);
|
||||
// The one badge is on the package, not the folder.
|
||||
expect(out.match(/Recommended/g)).toHaveLength(1);
|
||||
expect(out).not.toMatch(/A game folder<\/span><span[^>]*>Recommended/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OptionsCard for a folder", () => {
|
||||
it("has no compression, image or language choice, only the folder line and verification", () => {
|
||||
const out = renderToStaticMarkup(
|
||||
<OptionsCard
|
||||
outputDir="/out"
|
||||
onChangeOutput={() => {}}
|
||||
onOutputTyped={() => {}}
|
||||
canBrowse
|
||||
compression="balanced"
|
||||
onCompression={() => {}}
|
||||
estimates={undefined}
|
||||
locked={false}
|
||||
make="folder"
|
||||
language=""
|
||||
onLanguage={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(out).toContain("options-folder");
|
||||
expect(out).not.toContain("fpkg-language");
|
||||
expect(out).not.toContain("Balanced");
|
||||
expect(out).not.toContain("options-image");
|
||||
expect(out).toContain("Full verification");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OptionsCard for an image", () => {
|
||||
@@ -308,9 +346,11 @@ describe("QueueCard adding", () => {
|
||||
item("old"),
|
||||
item("small", { kind: "image", format: "ffpfs", compress: true }),
|
||||
item("plain", { kind: "image", format: "exfat", compress: false }),
|
||||
item("back", { kind: "folder" }),
|
||||
]);
|
||||
expect(html).toContain(".pkg");
|
||||
expect(html).toContain(".ffpfsc");
|
||||
expect(html).toContain(".exfat");
|
||||
expect(html).toMatch(/data-testid="cq-ext"[^>]*>folder</);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,13 @@ import { profileNameForHost, useRosterStore } from "../../state/roster";
|
||||
import { useWebviewDrop } from "../../lib/useWebviewDrop";
|
||||
import { useConnectionStore } from "../../state/connection";
|
||||
import { useConvertPrefs } from "../../state/convertPrefs";
|
||||
import { isArchiveSource, makesImage, useFpkgConversion } from "../../state/fpkgConversion";
|
||||
import {
|
||||
isArchiveSource,
|
||||
isFolderableSource,
|
||||
makesFolder,
|
||||
makesImage,
|
||||
useFpkgConversion,
|
||||
} from "../../state/fpkgConversion";
|
||||
import { useUploadStore } from "../../state/upload";
|
||||
import { DEFAULT_IMAGE_SUBPATH } from "../../lib/imageUpload";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
@@ -73,6 +79,7 @@ export default function FpkgConvertScreen() {
|
||||
const start = useFpkgConversion((s) => s.start);
|
||||
const compress = useFpkgConversion((s) => s.compress);
|
||||
const buildImage = useFpkgConversion((s) => s.buildImage);
|
||||
const buildFolder = useFpkgConversion((s) => s.buildFolder);
|
||||
const retryInstall = useFpkgConversion((s) => s.retryInstall);
|
||||
const cancel = useFpkgConversion((s) => s.cancel);
|
||||
const reset = useFpkgConversion((s) => s.reset);
|
||||
@@ -96,7 +103,7 @@ export default function FpkgConvertScreen() {
|
||||
const setImageChoice = useConvertPrefs((s) => s.setImageChoice);
|
||||
useEffect(() => {
|
||||
if (pipeline.phase === "running") {
|
||||
const building = makesImage(pipeline.mode) ? "image" : "pkg";
|
||||
const building = makesFolder(pipeline.mode) ? "folder" : makesImage(pipeline.mode) ? "image" : "pkg";
|
||||
if (building !== make) setMake(building);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -108,15 +115,21 @@ export default function FpkgConvertScreen() {
|
||||
const [lz4Profile, setLz4Profile] = useState<{ name: string; text: string } | null>(null);
|
||||
const [lz4InstallRuntime, setLz4InstallRuntime] = useState(false);
|
||||
const build: ConvertBuild =
|
||||
make === "image" ? { kind: "image", format: imageFormat, compress: imageCompress } : { kind: "pkg" };
|
||||
make === "image"
|
||||
? { kind: "image", format: imageFormat, compress: imageCompress }
|
||||
: make === "folder"
|
||||
? { kind: "folder" }
|
||||
: { kind: "pkg" };
|
||||
const then = thenFor(build.kind, thenChoice, canInstall);
|
||||
/** "skipped": an image was asked for a source that is not a game folder here. */
|
||||
/** "skipped": an image was asked for a source that is not a game folder here, or a folder
|
||||
* for one that is not an image or package. */
|
||||
const queueAdd = (
|
||||
src: string,
|
||||
isFolder: boolean,
|
||||
lz4?: AmprLz4Options,
|
||||
): boolean | "skipped" => {
|
||||
if (build.kind === "image" && !isFolder) return "skipped";
|
||||
if (build.kind === "folder" && !isFolderableSource(src)) return "skipped";
|
||||
const u = useUploadStore.getState();
|
||||
return useConvertQueue.getState().add({
|
||||
source: src,
|
||||
@@ -142,25 +155,23 @@ export default function FpkgConvertScreen() {
|
||||
tr("cq_not_games", { count: notGames }, "{count} folder(s) were not games and were skipped."),
|
||||
);
|
||||
const skipped = results.filter((r) => r === "skipped").length;
|
||||
if (skipped)
|
||||
notes.push(
|
||||
tr(
|
||||
"cq_image_folders_only",
|
||||
{ count: skipped },
|
||||
"Only a game folder on this computer can become a game image: {count} skipped.",
|
||||
),
|
||||
);
|
||||
if (skipped) notes.push(skippedText(skipped));
|
||||
if (results.some((r) => r === false)) notes.push(tr("cq_already", undefined, "This game is already in the queue."));
|
||||
setError(notes.length ? notes.join(" ") : null);
|
||||
};
|
||||
const skippedNote = (n: number) =>
|
||||
setError(
|
||||
tr(
|
||||
"cq_image_folders_only",
|
||||
{ count: n },
|
||||
"Only a game folder on this computer can become a game image: {count} skipped.",
|
||||
),
|
||||
);
|
||||
const skippedText = (n: number) =>
|
||||
build.kind === "folder"
|
||||
? tr(
|
||||
"cq_folder_sources_only",
|
||||
{ count: n },
|
||||
"Only a game image or a package made with the debug key can become a folder: {count} skipped.",
|
||||
)
|
||||
: tr(
|
||||
"cq_image_folders_only",
|
||||
{ count: n },
|
||||
"Only a game folder on this computer can become a game image: {count} skipped.",
|
||||
);
|
||||
const skippedNote = (n: number) => setError(skippedText(n));
|
||||
// The console's swap journals are read through these; one set per console.
|
||||
const swapDeps = useMemo(() => (canInstall ? consoleSwapDeps(host) : null), [canInstall, host]);
|
||||
const installTaskId = pipeline.phase === "running" ? pipeline.installTaskId : null;
|
||||
@@ -274,7 +285,7 @@ export default function FpkgConvertScreen() {
|
||||
mode,
|
||||
title,
|
||||
filters:
|
||||
mode === "file" ? [{ name: "Game image or archive", extensions: ["exfat", "ffpkg", "ffpfs", "ffpfsc", "zip", "7z", "rar"] }] : undefined,
|
||||
mode === "file" ? [{ name: "Game image, package or archive", extensions: ["exfat", "ffpkg", "ffpfs", "ffpfsc", "pkg", "zip", "7z", "rar"] }] : undefined,
|
||||
});
|
||||
if (picked) chooseSource(picked);
|
||||
} catch {
|
||||
@@ -295,7 +306,7 @@ export default function FpkgConvertScreen() {
|
||||
const picked = await pickPath({
|
||||
mode: "any",
|
||||
title: tr("fpkg.pickConsole", undefined, "Choose a game folder or image on the PS5"),
|
||||
filters: [{ name: "Game image", extensions: ["exfat", "ffpkg", "ffpfsc"] }],
|
||||
filters: [{ name: "Game image or package", extensions: ["exfat", "ffpkg", "ffpfs", "ffpfsc", "pkg"] }],
|
||||
source: { console: host },
|
||||
});
|
||||
if (picked) chooseSource(picked);
|
||||
@@ -316,6 +327,10 @@ export default function FpkgConvertScreen() {
|
||||
const run = () => {
|
||||
if (!source.trim()) return;
|
||||
setError(null);
|
||||
if (build.kind === "folder") {
|
||||
void buildFolder(src, outputDir.trim() || undefined);
|
||||
return;
|
||||
}
|
||||
if (build.kind === "image") {
|
||||
const u = useUploadStore.getState();
|
||||
void buildImage(
|
||||
@@ -400,6 +415,10 @@ export default function FpkgConvertScreen() {
|
||||
// package build reads it). An archive cannot from here: unpack it first.
|
||||
const src = source.trim();
|
||||
const isLocalFolder = inspection !== null && !noFiles && !isArchiveSource(src);
|
||||
// A game image or a package can be unpacked into a plain folder, wherever it is; a folder is
|
||||
// one already, and an archive is unpacked first.
|
||||
const canFolder = src !== "" && isFolderableSource(src);
|
||||
const isFolderSource = src !== "" && !canFolder && !isArchiveSource(src);
|
||||
// A profile measured from play (the game page's Measure how it plays) is the LZ4 default
|
||||
// for this title; a file picked by hand replaces it.
|
||||
const measuredFor = titleIdOf(inspection?.content_id);
|
||||
@@ -498,6 +517,7 @@ export default function FpkgConvertScreen() {
|
||||
onMake={(m) => {
|
||||
setMake(m);
|
||||
if (m === "image" && then === "stream") setThen("upload");
|
||||
if (m === "folder") setThen("keep");
|
||||
}}
|
||||
kernel={kernel}
|
||||
contentId={inspection?.content_id}
|
||||
@@ -566,6 +586,8 @@ export default function FpkgConvertScreen() {
|
||||
}}
|
||||
onRun={run}
|
||||
canImage={isLocalFolder}
|
||||
canFolder={canFolder}
|
||||
isFolderSource={isFolderSource}
|
||||
summary={
|
||||
inspection
|
||||
? tr(
|
||||
@@ -574,7 +596,9 @@ export default function FpkgConvertScreen() {
|
||||
what:
|
||||
build.kind === "image"
|
||||
? tr("fpkg.run.what_image", { ext: buildExtension(build) }, "a {ext} game image")
|
||||
: tr("fpkg.run.what_pkg", undefined, "a package (.pkg)"),
|
||||
: build.kind === "folder"
|
||||
? tr("fpkg.run.what_folder", undefined, "a game folder")
|
||||
: tr("fpkg.run.what_pkg", undefined, "a package (.pkg)"),
|
||||
title: inspection.title || inspection.content_id || source.trim(),
|
||||
dir: outputDir.trim() || "~/Downloads/fpkgs",
|
||||
},
|
||||
@@ -608,7 +632,8 @@ export default function FpkgConvertScreen() {
|
||||
isTauriEnv() && engineIsOnThisDevice()
|
||||
? () => {
|
||||
if (pipeline.phase !== "done") return;
|
||||
const dir = dirOf(pipeline.packagePath);
|
||||
// A folder build's result is the folder itself: open it.
|
||||
const dir = makesFolder(pipeline.mode) ? pipeline.packagePath : dirOf(pipeline.packagePath);
|
||||
void openLocalPath(dir).then((ok) => {
|
||||
if (!ok)
|
||||
setError(
|
||||
@@ -681,7 +706,7 @@ export default function FpkgConvertScreen() {
|
||||
void (async () => {
|
||||
const picked = await pickPaths({
|
||||
title: tr("fpkg.pickImage", undefined, "Choose a game image or archive"),
|
||||
filters: [{ name: "Game image or archive", extensions: ["exfat", "ffpkg", "ffpfs", "ffpfsc", "zip", "7z", "rar"] }],
|
||||
filters: [{ name: "Game image, package or archive", extensions: ["exfat", "ffpkg", "ffpfs", "ffpfsc", "pkg", "zip", "7z", "rar"] }],
|
||||
}).catch(() => [] as string[]);
|
||||
queueAll(
|
||||
picked.map((path) => ({ path, isDir: false })),
|
||||
|
||||
@@ -150,4 +150,18 @@ describe("stageRows", () => {
|
||||
);
|
||||
expect(plain.map((r) => r.stage)).not.toContain("pack");
|
||||
});
|
||||
|
||||
it("a folder build writes then reads back, reading a server image or package in place", () => {
|
||||
for (const source of ["/g/Game.ffpfsc", "remote://nas-1/g/Game.pkg", "ps5://10.0.0.2/data/g.exfat"]) {
|
||||
const rows = stageRows(
|
||||
{ phase: "running", ...base, source, mode: "folder", stage: "verify", stageDone: 3, stageTotal: 6, stageMs: { write: 7 } },
|
||||
null,
|
||||
);
|
||||
expect(rows.map((r) => [r.stage, r.state])).toEqual([
|
||||
["write", "done"],
|
||||
["verify", "active"],
|
||||
]);
|
||||
expect(rows[1]).toMatchObject({ done: 3, total: 6 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ export interface StageRow {
|
||||
const BUILD: readonly PipelineStage[] = ["check", "plan", "compress", "write", "verify"];
|
||||
/** An image whose files are packed into AMPR LZ4 asset packs first. */
|
||||
const PACKED_BUILD: readonly PipelineStage[] = ["check", "pack", "plan", "compress", "write", "verify"];
|
||||
/** A plain folder from an image or package: written, then read back. */
|
||||
const FOLDER: readonly PipelineStage[] = ["write", "verify"];
|
||||
const INSTALL: readonly PipelineStage[] = ["send", "install"];
|
||||
/** A console dump is set aside, then its package installs (see lib/dumpSwap.ts). */
|
||||
const SWAP: readonly PipelineStage[] = ["park", "install"];
|
||||
@@ -41,6 +43,8 @@ const WEIGHT: Record<PipelineStage, number> = {
|
||||
};
|
||||
|
||||
function stagesFor(mode: PipelineMode, source: string, packs: boolean): readonly PipelineStage[] {
|
||||
// The engine reads the image or package in place, wherever it is.
|
||||
if (mode === "folder") return FOLDER;
|
||||
const copy: PipelineStage[] = [];
|
||||
// A build reads a server folder or image in place; a server archive, or an image being
|
||||
// compressed (which reads local disk), is copied first.
|
||||
|
||||
@@ -62,4 +62,14 @@ describe("convertPrefs", () => {
|
||||
expect(parseFullVerify("1")).toBe(true);
|
||||
for (const v of [null, "", "0", "true", "yes"]) expect(parseFullVerify(v)).toBe(false);
|
||||
});
|
||||
|
||||
it("remembers a folder as what to make, and reads anything else as a package", async () => {
|
||||
const first = await import("./convertPrefs");
|
||||
first.useConvertPrefs.getState().setMake("folder");
|
||||
vi.resetModules();
|
||||
const second = await import("./convertPrefs");
|
||||
expect(second.useConvertPrefs.getState().make).toBe("folder");
|
||||
expect(second.parseMake("image")).toBe("image");
|
||||
for (const v of [null, "", "dir", "pkg"]) expect(second.parseMake(v)).toBe("pkg");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,10 @@ export const VERIFY_CHUNK_KIB: readonly number[] = [1024, 4096, 8192, 16384, 327
|
||||
* default. */
|
||||
export const DEFAULT_VERIFY_CHUNK_KIB = 8192;
|
||||
|
||||
/** What Convert makes: a package, a game image, or a plain game folder (from an image or a
|
||||
* debug-key package). */
|
||||
export type MakeKind = "pkg" | "image" | "folder";
|
||||
|
||||
interface ConvertPrefs {
|
||||
outputDir: string;
|
||||
compression: FpkgCompression;
|
||||
@@ -34,10 +38,10 @@ interface ConvertPrefs {
|
||||
fullVerify: boolean;
|
||||
/** What Convert makes, and which image: remembered so leaving the screen mid-build and
|
||||
* coming back shows the build that is running, not the default. */
|
||||
make: "pkg" | "image";
|
||||
make: MakeKind;
|
||||
imageFormat: ImageFormat;
|
||||
imageCompress: boolean;
|
||||
setMake: (m: "pkg" | "image") => void;
|
||||
setMake: (m: MakeKind) => void;
|
||||
setImageChoice: (format: ImageFormat, compress: boolean) => void;
|
||||
setOutputDir: (dir: string) => void;
|
||||
setCompression: (c: FpkgCompression) => void;
|
||||
@@ -59,8 +63,8 @@ export function parseFullVerify(v: string | null): boolean {
|
||||
return v === "1";
|
||||
}
|
||||
|
||||
export function parseMake(v: string | null): "pkg" | "image" {
|
||||
return v === "image" ? "image" : "pkg";
|
||||
export function parseMake(v: string | null): MakeKind {
|
||||
return v === "image" || v === "folder" ? v : "pkg";
|
||||
}
|
||||
|
||||
export function parseImageFormat(v: string | null): ImageFormat {
|
||||
|
||||
@@ -2,7 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
import { hydrateConvertQueue, setConvertRunner, thenFor, useConvertQueue, type ConvertRunner } from "./convertQueue";
|
||||
import {
|
||||
buildExtension,
|
||||
hydrateConvertQueue,
|
||||
setConvertRunner,
|
||||
thenFor,
|
||||
useConvertQueue,
|
||||
type ConvertRunner,
|
||||
} from "./convertQueue";
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -102,6 +109,12 @@ describe("what happens after converting", () => {
|
||||
expect(thenFor("image", "keep", true)).toBe("keep");
|
||||
});
|
||||
|
||||
it("only keeps a folder, and names it as one", () => {
|
||||
expect(thenFor("folder", "stream", true)).toBe("keep");
|
||||
expect(thenFor("folder", "upload", true)).toBe("keep");
|
||||
expect(buildExtension({ kind: "folder" })).toBe("folder");
|
||||
});
|
||||
|
||||
it("keeps the result when no console answers", () => {
|
||||
expect(thenFor("pkg", "stream", false)).toBe("keep");
|
||||
expect(thenFor("image", "upload", false)).toBe("keep");
|
||||
|
||||
@@ -12,23 +12,27 @@ import { safeGetItem, safeSetItem } from "../lib/safeStorage";
|
||||
export type ConvertThen = "keep" | "stream" | "upload";
|
||||
export type ConvertStatus = "pending" | "running" | "installing" | "handed" | "done" | "failed";
|
||||
|
||||
/** What a queued game becomes: an installable package, or a game image (compressed into a
|
||||
* .ffpfsc when `compress`). Only a game folder can become an image. `lz4`: pack the game's
|
||||
* data into AMPR LZ4 asset packs (an .exfat of a game that reads through libSceAmpr). */
|
||||
/** What a queued game becomes: an installable package, a game image (compressed into a
|
||||
* .ffpfsc when `compress`), or a plain folder (#469: from a game image or a debug-key
|
||||
* package). `lz4`: pack the game's data into AMPR LZ4 asset packs (an .exfat of a game that
|
||||
* reads through libSceAmpr). */
|
||||
export type ConvertBuild =
|
||||
| { kind: "pkg" }
|
||||
| { kind: "image"; format: ImageFormat; compress: boolean; lz4?: AmprLz4Options };
|
||||
| { kind: "image"; format: ImageFormat; compress: boolean; lz4?: AmprLz4Options }
|
||||
| { kind: "folder" };
|
||||
|
||||
/** The file a build makes, by its extension: what the queue row and the add button say. */
|
||||
/** The file a build makes, by its extension: what the queue row and the add button say. A
|
||||
* folder has none; its row says "folder". */
|
||||
export function buildExtension(build: ConvertBuild | undefined): string {
|
||||
if (!build || build.kind === "pkg") return ".pkg";
|
||||
if (build.kind === "folder") return "folder";
|
||||
return build.compress ? ".ffpfsc" : `.${build.format}`;
|
||||
}
|
||||
|
||||
/** What is done after converting, as it can be done: an image is never streamed (it is
|
||||
* uploaded), and nothing is sent anywhere while no console answers. */
|
||||
* uploaded), a folder is only kept, and nothing is sent anywhere while no console answers. */
|
||||
export function thenFor(kind: ConvertBuild["kind"], then: ConvertThen, canInstall: boolean): ConvertThen {
|
||||
if (!canInstall) return "keep";
|
||||
if (!canInstall || kind === "folder") return "keep";
|
||||
return kind === "image" && then === "stream" ? "upload" : then;
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,30 @@ describe("fpkg pipeline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("unpacks an image or package into a folder: write, then verify, ending at the folder", async () => {
|
||||
const buildFolder = vi.fn().mockResolvedValue({ job_id: "f1" });
|
||||
const { fpkg } = await import("../api/fpkg");
|
||||
(fpkg as unknown as { buildFolder: unknown }).buildFolder = buildFolder;
|
||||
jobStatus.mockResolvedValueOnce({
|
||||
status: "running",
|
||||
bytes_sent: 4,
|
||||
total_bytes: 9,
|
||||
stage: { id: "verify", index: 1, count: 2, done: 2, total: 5 },
|
||||
});
|
||||
await useFpkgConversion.getState().buildFolder("/games/Game.ffpfsc", "/out");
|
||||
expect(useFpkgConversion.getState().pipeline).toMatchObject({ phase: "running", mode: "folder", stage: "write" });
|
||||
await tick();
|
||||
expect(buildFolder).toHaveBeenCalledWith("/games/Game.ffpfsc", "/out", undefined, false);
|
||||
expect(useFpkgConversion.getState().pipeline).toMatchObject({ stage: "verify", stageDone: 2, stageTotal: 5 });
|
||||
jobStatus.mockResolvedValueOnce({ status: "done", dest: "/out/Game", bytes_sent: 9 });
|
||||
await tick();
|
||||
const p = useFpkgConversion.getState().pipeline;
|
||||
expect(p).toMatchObject({ phase: "done", mode: "folder", packagePath: "/out/Game" });
|
||||
// A folder is never installed.
|
||||
await useFpkgConversion.getState().retryInstall("10.0.0.2");
|
||||
expect(useFpkgConversion.getState().pipeline).toBe(p);
|
||||
});
|
||||
|
||||
it("puts the finished image in the Upload queue when the run was asked to", async () => {
|
||||
const buildImage = vi.fn().mockResolvedValue({ job_id: "i9" });
|
||||
const { fpkg } = await import("../api/fpkg");
|
||||
|
||||
@@ -38,12 +38,16 @@ export type PipelineStage =
|
||||
| "install";
|
||||
|
||||
/** What a run does: a package, a package then its install, an install of a kept package, a
|
||||
* compressed .ffpfsc image from an image, or a game image (.exfat) from a folder. */
|
||||
export type PipelineMode = "convert" | "convert-install" | "install" | "ffpfsc" | "image";
|
||||
* compressed .ffpfsc image from an image, a game image (.exfat) from a folder, or a plain
|
||||
* folder from an image or a debug-key package. */
|
||||
export type PipelineMode = "convert" | "convert-install" | "install" | "ffpfsc" | "image" | "folder";
|
||||
|
||||
/** A run whose result is a game image to mount, not a package to install. */
|
||||
export const makesImage = (mode: PipelineMode) => mode === "ffpfsc" || mode === "image";
|
||||
|
||||
/** A run whose result is a plain game folder on this computer. */
|
||||
export const makesFolder = (mode: PipelineMode) => mode === "folder";
|
||||
|
||||
/** How a built package reaches the console: streamed from this computer over HTTP (nothing
|
||||
* staged), or uploaded to PS5 staging and installed from there (never needs the console to
|
||||
* reach this computer). */
|
||||
@@ -136,6 +140,8 @@ export interface ConversionState {
|
||||
/** Pack the game's files into AMPR LZ4 asset packs first (an .exfat image). */
|
||||
lz4?: AmprLz4Options,
|
||||
) => Promise<void>;
|
||||
/** Unpack a game image or a debug-key package into a plain folder in `outputDir`. */
|
||||
buildFolder: (source: string, outputDir: string | undefined) => Promise<void>;
|
||||
/** Put the finished image in the Upload queue. */
|
||||
uploadImage: (plan: ImageUploadPlan) => void;
|
||||
/** Install the kept package again: after a failed install, or Install again on a result. */
|
||||
@@ -233,7 +239,9 @@ function fail(stage: PipelineStage, message: string, packagePath: string | null)
|
||||
? "Compression"
|
||||
: p.mode === "image"
|
||||
? "Making the game image"
|
||||
: p.mode === "convert"
|
||||
: p.mode === "folder"
|
||||
? "Making the game folder"
|
||||
: p.mode === "convert"
|
||||
? "FPKG conversion"
|
||||
: "Convert & install";
|
||||
useFpkgConversion.setState({
|
||||
@@ -504,7 +512,9 @@ function poll(jobId: string, install: InstallMethod | null, failures = 0) {
|
||||
? "Compression complete"
|
||||
: cur.mode === "image"
|
||||
? "Game image ready"
|
||||
: "FPKG conversion complete",
|
||||
: cur.mode === "folder"
|
||||
? "Game folder ready"
|
||||
: "FPKG conversion complete",
|
||||
{ body: path, link: "/convert" },
|
||||
);
|
||||
}
|
||||
@@ -523,7 +533,7 @@ function beginRun(mode: PipelineMode, source: string, host: string | null, stage
|
||||
: useTaskStore.getState().registerTask({
|
||||
kind: mode === "ffpfsc" ? "ffpfsc-compress" : "fpkg-convert",
|
||||
origin: "convert",
|
||||
label: `${mode === "ffpfsc" ? "Compress" : mode === "image" ? "Make image of" : "Convert"} ${baseName(source)}`,
|
||||
label: `${mode === "ffpfsc" ? "Compress" : mode === "image" ? "Make image of" : mode === "folder" ? "Make folder of" : "Convert"} ${baseName(source)}`,
|
||||
detail: source,
|
||||
consoleId: host ?? "",
|
||||
control: { owner: "fpkg-convert" },
|
||||
@@ -557,6 +567,13 @@ export function isArchiveSource(path: string): boolean {
|
||||
return /\.(zip|7z|rar)$/i.test(path.trim());
|
||||
}
|
||||
|
||||
/** A game image (`.exfat`, `.ffpkg`/`.ufs2`, `.ffpfs`, `.ffpfsc`) or a package (`.pkg`): what
|
||||
* can be unpacked into a plain folder. A package has to be made with the debug key; the
|
||||
* engine says so when it is not. */
|
||||
export function isFolderableSource(path: string): boolean {
|
||||
return /\.(exfat|ffpkg|ufs2|ffpfs|ffpfsc|pkg)$/i.test(path.trim());
|
||||
}
|
||||
|
||||
/** The engine's archive errors, said for a person. */
|
||||
export function archiveErrorText(message: string): string {
|
||||
if (/rar_password_required/.test(message))
|
||||
@@ -761,12 +778,25 @@ export const useFpkgConversion = create<ConversionState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
buildFolder: async (source, outputDir) => {
|
||||
if (get().pipeline.phase === "running") return;
|
||||
pendingImageUpload = null;
|
||||
beginRun("folder", source, null, "write");
|
||||
try {
|
||||
const { job_id } = await fpkg.buildFolder(source, outputDir, undefined, useConvertPrefs.getState().fullVerify);
|
||||
update({ jobId: job_id });
|
||||
poll(job_id, null);
|
||||
} catch (error) {
|
||||
fail("write", error instanceof Error ? error.message : String(error), null);
|
||||
}
|
||||
},
|
||||
|
||||
retryInstall: async (host, method = "stream") => {
|
||||
const p = get().pipeline;
|
||||
const path =
|
||||
p.phase === "failed" ? p.packagePath : p.phase === "done" && !p.deleted ? p.packagePath : null;
|
||||
if (!path || (p.phase !== "failed" && p.phase !== "done")) return;
|
||||
if (p.phase === "done" && makesImage(p.mode)) return;
|
||||
if (makesFolder(p.mode) || (p.phase === "done" && makesImage(p.mode))) return;
|
||||
deleteAfterInstall = false;
|
||||
beginRun("install", p.source, host, "send");
|
||||
update({ packagePath: path, titleId: p.titleId });
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
//! A game folder from a game image or a debug-key package (#469): the reverse of Convert's
|
||||
//! image and package builds.
|
||||
//!
|
||||
//! The rules the image builds follow hold here too. Every path is checked before a byte is
|
||||
//! written; the folder is written as `<name>.partial/` and only renamed to its name once it
|
||||
//! reads back; and the read-back compares hashes (SHA-256), not only names and sizes: of every
|
||||
//! file on request (full), of a seeded sample otherwise (`ps5upload_fpkg::sample`). The sample
|
||||
//! is re-read from a freshly opened source, so a reader that returned the wrong bytes the first
|
||||
//! time is not trusted to return the same wrong bytes again.
|
||||
//!
|
||||
//! A `.partial` folder carries [`MARKER`] while it is written, which is how the output folder's
|
||||
//! leftover sweep tells our unfinished folders from a user's own `something.partial`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use ps5upload_fpkg::sample::{self, VerifyMode, VerifySummary};
|
||||
use ps5upload_fpkg::source::SourceTree;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// How much of a file is read from the source per call while writing and verifying.
|
||||
pub(crate) const CHUNK: u64 = 8 << 20;
|
||||
|
||||
/// Room a folder build keeps beyond the files themselves (directory entries, the marker).
|
||||
pub(crate) const SPARE: u64 = 64 << 20;
|
||||
|
||||
/// The file inside a `<name>.partial/` folder that says this engine is writing it.
|
||||
pub(crate) const MARKER: &str = ".ps5upload-partial";
|
||||
|
||||
/// The unfinished folder a build of `target` writes into.
|
||||
pub(crate) fn partial_of(target: &Path) -> PathBuf {
|
||||
PathBuf::from(format!("{}.partial", target.display()))
|
||||
}
|
||||
|
||||
/// True when `dir` is an unfinished folder build of ours (it holds [`MARKER`]).
|
||||
pub(crate) fn is_our_partial_dir(dir: &Path) -> bool {
|
||||
dir.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.to_ascii_lowercase().ends_with(".partial"))
|
||||
&& dir.join(MARKER).is_file()
|
||||
}
|
||||
|
||||
/// The refusal a folder of `total` bytes deserves with `free` bytes left in `out`, or `None`.
|
||||
pub(crate) fn shortfall(free: u64, total: u64, out: &Path) -> Option<String> {
|
||||
let needed = total.saturating_add(SPARE);
|
||||
(free < needed).then(|| {
|
||||
format!(
|
||||
"only {:.1} GiB free in {}, and the folder needs about {:.1} GiB",
|
||||
free as f64 / (1u64 << 30) as f64,
|
||||
out.display(),
|
||||
needed as f64 / (1u64 << 30) as f64,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// What a finished folder build reports.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FolderBuilt {
|
||||
pub files: u64,
|
||||
pub bytes: u64,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Why one name cannot be a file or folder name on `windows` (or on a Unix system), or `None`.
|
||||
fn bad_component(name: &str, windows: bool) -> Option<&'static str> {
|
||||
if name.is_empty() {
|
||||
return Some("has an empty name in its path");
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return Some("points outside its folder");
|
||||
}
|
||||
if name.contains('\0') {
|
||||
return Some("has a NUL character in its name");
|
||||
}
|
||||
if name.len() > 255 {
|
||||
return Some("has a name longer than 255 bytes");
|
||||
}
|
||||
if windows {
|
||||
if name
|
||||
.chars()
|
||||
.any(|c| (c as u32) < 0x20 || "<>:\"\\|?*".contains(c))
|
||||
{
|
||||
return Some("has a character Windows does not allow in a name (<>:\"\\|?* or a control character)");
|
||||
}
|
||||
if name.ends_with('.') || name.ends_with(' ') {
|
||||
return Some("ends with a dot or space, which Windows drops");
|
||||
}
|
||||
let base = name
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_end()
|
||||
.to_ascii_uppercase();
|
||||
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| ((base.starts_with("COM") || base.starts_with("LPT"))
|
||||
&& base.len() == 4
|
||||
&& base.as_bytes()[3].is_ascii_digit()
|
||||
&& base.as_bytes()[3] != b'0');
|
||||
if reserved {
|
||||
return Some("has a name Windows reserves for a device (CON, NUL, COM1, …)");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Why `path` (relative, `/`-separated) cannot be written under a folder, or `None`.
|
||||
pub(crate) fn path_problem(path: &str, windows: bool) -> Option<&'static str> {
|
||||
if path.starts_with('/') || path.starts_with('\\') {
|
||||
return Some("is an absolute path");
|
||||
}
|
||||
if windows && path.as_bytes().get(1) == Some(&b':') {
|
||||
return Some("is an absolute path");
|
||||
}
|
||||
path.split('/').find_map(|c| bad_component(c, windows))
|
||||
}
|
||||
|
||||
/// Why a folder name a caller gave cannot be used, or `None`.
|
||||
pub(crate) fn name_problem(name: &str) -> Option<&'static str> {
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return Some("has a path separator in it");
|
||||
}
|
||||
if name.ends_with(".partial") {
|
||||
return Some("ends with .partial, which marks an unfinished folder");
|
||||
}
|
||||
bad_component(name, cfg!(windows))
|
||||
}
|
||||
|
||||
/// Every path checked before anything is written: each must be a name this OS can hold, no
|
||||
/// file may also be a folder, and on a case-insensitive drive (`fold_case`) no two may differ
|
||||
/// only by case. The error names the file.
|
||||
fn check_paths(tree: &dyn SourceTree, fold_case: bool) -> Result<(), String> {
|
||||
let windows = cfg!(windows);
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut dirs: HashSet<String> = HashSet::new();
|
||||
let key = |p: &str| {
|
||||
if fold_case {
|
||||
p.to_lowercase()
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
};
|
||||
let mut all: Vec<(&str, bool)> = tree
|
||||
.files()
|
||||
.iter()
|
||||
.map(|f| (f.path.as_str(), true))
|
||||
.collect();
|
||||
all.extend(
|
||||
tree.empty_dirs()
|
||||
.iter()
|
||||
.map(|d| (d.trim_end_matches('/'), false)),
|
||||
);
|
||||
for (path, _) in &all {
|
||||
if let Some(why) = path_problem(path, windows) {
|
||||
return Err(format!("{path} {why}; nothing was written"));
|
||||
}
|
||||
if path.split('/').next() == Some(MARKER) {
|
||||
return Err(format!(
|
||||
"{path} has the name this app uses to mark an unfinished folder; nothing was written"
|
||||
));
|
||||
}
|
||||
}
|
||||
for (path, is_file) in &all {
|
||||
let mut at = String::new();
|
||||
for c in path.split('/').take(path.split('/').count() - 1) {
|
||||
if !at.is_empty() {
|
||||
at.push('/');
|
||||
}
|
||||
at.push_str(c);
|
||||
dirs.insert(key(&at));
|
||||
}
|
||||
if !is_file {
|
||||
dirs.insert(key(path));
|
||||
}
|
||||
}
|
||||
for (path, is_file) in &all {
|
||||
if !is_file {
|
||||
continue;
|
||||
}
|
||||
let k = key(path);
|
||||
if dirs.contains(&k) {
|
||||
return Err(format!(
|
||||
"{path} is both a file and a folder in the source; nothing was written"
|
||||
));
|
||||
}
|
||||
if !seen.insert(k) {
|
||||
return Err(format!(
|
||||
"{path} appears twice{}; nothing was written",
|
||||
if fold_case {
|
||||
" (names that differ only by case are one name on this drive)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file names on the drive under `dir` ignore case: `MARKER` (just written there) can be
|
||||
/// opened by its upper-case name.
|
||||
fn drive_folds_case(dir: &Path) -> bool {
|
||||
dir.join(MARKER.to_ascii_uppercase()).exists()
|
||||
}
|
||||
|
||||
/// Copies `path` out of `tree` into `dest`, `CHUNK` bytes at a time.
|
||||
fn copy_file(
|
||||
tree: &mut dyn SourceTree,
|
||||
path: &str,
|
||||
size: u64,
|
||||
dest: &Path,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(u64),
|
||||
) -> Result<(), String> {
|
||||
let mut out = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(dest)
|
||||
.map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut at = 0u64;
|
||||
while at < size {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let want = CHUNK.min(size - at) as usize;
|
||||
let bytes = tree
|
||||
.read_range(path, at, want)
|
||||
.map_err(|e| format!("{path}: {e}"))?;
|
||||
if bytes.is_empty() {
|
||||
return Err(format!("{path}: the source ended at byte {at} of {size}"));
|
||||
}
|
||||
let bytes = &bytes[..bytes.len().min(want)];
|
||||
out.write_all(bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
at += bytes.len() as u64;
|
||||
progress(bytes.len() as u64);
|
||||
}
|
||||
out.flush().map_err(|e| format!("{path}: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SHA-256 of `len` bytes at `offset` of `path` in `tree`.
|
||||
fn hash_source(
|
||||
tree: &mut dyn SourceTree,
|
||||
path: &str,
|
||||
offset: u64,
|
||||
len: u64,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<[u8; 32], String> {
|
||||
let mut h = Sha256::new();
|
||||
let mut at = 0u64;
|
||||
while at < len {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let want = CHUNK.min(len - at) as usize;
|
||||
let bytes = tree
|
||||
.read_range(path, offset + at, want)
|
||||
.map_err(|e| format!("{path}: {e}"))?;
|
||||
if bytes.is_empty() {
|
||||
return Err(format!("{path}: the source ended at byte {}", offset + at));
|
||||
}
|
||||
let bytes = &bytes[..bytes.len().min(want)];
|
||||
h.update(bytes);
|
||||
at += bytes.len() as u64;
|
||||
}
|
||||
Ok(h.finalize().into())
|
||||
}
|
||||
|
||||
/// SHA-256 of `len` bytes at `offset` of a written file.
|
||||
fn hash_written(
|
||||
file: &Path,
|
||||
offset: u64,
|
||||
len: u64,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<[u8; 32], String> {
|
||||
let mut f = std::fs::File::open(file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
f.seek(SeekFrom::Start(offset)).map_err(|e| e.to_string())?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; CHUNK.min(len.max(1)) as usize];
|
||||
let mut left = len;
|
||||
while left > 0 {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let n = buf.len().min(left as usize);
|
||||
f.read_exact(&mut buf[..n])
|
||||
.map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
h.update(&buf[..n]);
|
||||
left -= n as u64;
|
||||
}
|
||||
Ok(h.finalize().into())
|
||||
}
|
||||
|
||||
/// Reads the written folder back: every file at its size and every empty folder present, then
|
||||
/// the SHA-256 of what `mode` samples, the source side read through `fresh` (a newly opened
|
||||
/// source).
|
||||
fn verify(
|
||||
written: &Path,
|
||||
listed: &[(String, u64)],
|
||||
empty_dirs: &[String],
|
||||
fresh: &mut dyn SourceTree,
|
||||
mode: VerifyMode,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<VerifySummary, String> {
|
||||
for (path, size) in listed {
|
||||
let got = std::fs::metadata(written.join(path))
|
||||
.map_err(|e| format!("{path} did not read back: {e}"))?
|
||||
.len();
|
||||
if got != *size {
|
||||
return Err(format!(
|
||||
"{path} is {got} bytes in the folder and {size} in the source"
|
||||
));
|
||||
}
|
||||
}
|
||||
for d in empty_dirs {
|
||||
let d = d.trim_matches('/');
|
||||
if !written.join(d).is_dir() {
|
||||
return Err(format!("the empty folder {d} is missing"));
|
||||
}
|
||||
}
|
||||
let summary = sample::summary(listed, mode);
|
||||
let pieces: Vec<(usize, u64, u64)> = match mode {
|
||||
VerifyMode::Full => listed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| f.1 > 0)
|
||||
.map(|(i, f)| (i, 0, f.1))
|
||||
.collect(),
|
||||
VerifyMode::Fast { seed, seam } => sample::plan(listed, seed, seam)
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let (at, len) = s.range(listed[s.file_idx].1);
|
||||
(s.file_idx, at, len)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let total = summary.checked_bytes;
|
||||
let mut done = 0u64;
|
||||
progress(0, total);
|
||||
for (i, at, len) in pieces {
|
||||
let path = &listed[i].0;
|
||||
let want = hash_source(fresh, path, at, len, cancel)?;
|
||||
let got = hash_written(&written.join(path), at, len, cancel)?;
|
||||
if want != got {
|
||||
let range = if len != listed[i].1 {
|
||||
format!(" (bytes {at}..{})", at + len)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
return Err(format!(
|
||||
"{path} differs between the folder and the source{range}"
|
||||
));
|
||||
}
|
||||
done += len;
|
||||
progress(done, total);
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Writes `tree` as the folder `target`: checked, written into `<target>.partial/`, read back
|
||||
/// against a source `reopen` opens afresh, then renamed. `stage(id, done, total)` reports
|
||||
/// `write` and `verify`. The caller has claimed the `.partial` (`fpkg_api::Writing`); on any
|
||||
/// failure it is removed here.
|
||||
pub(crate) fn build(
|
||||
tree: &mut dyn SourceTree,
|
||||
reopen: &dyn Fn() -> Result<Box<dyn SourceTree>, String>,
|
||||
target: &Path,
|
||||
mode: VerifyMode,
|
||||
cancel: &AtomicBool,
|
||||
stage: &mut dyn FnMut(&str, u64, u64),
|
||||
) -> Result<FolderBuilt, String> {
|
||||
if tree.files().is_empty() {
|
||||
return Err(format!("{} has no files in it", tree.describe()));
|
||||
}
|
||||
if target.exists() {
|
||||
return Err(format!("{} already exists", target.display()));
|
||||
}
|
||||
// Checked before the partial folder exists; the case check needs the drive, below.
|
||||
check_paths(tree, false)?;
|
||||
let partial = partial_of(target);
|
||||
// Not `create_dir_all`: a folder here now is another build's, never written into.
|
||||
std::fs::create_dir(&partial).map_err(|e| format!("{}: {e}", partial.display()))?;
|
||||
let result = (|| -> Result<FolderBuilt, String> {
|
||||
std::fs::write(partial.join(MARKER), b"").map_err(|e| e.to_string())?;
|
||||
if drive_folds_case(&partial) {
|
||||
check_paths(tree, true)?;
|
||||
}
|
||||
let listed: Vec<(String, u64)> = tree
|
||||
.files()
|
||||
.iter()
|
||||
.map(|f| (f.path.clone(), f.size))
|
||||
.collect();
|
||||
let empty_dirs: Vec<String> = tree.empty_dirs().to_vec();
|
||||
let total: u64 = listed.iter().map(|f| f.1).sum();
|
||||
|
||||
let mut written = 0u64;
|
||||
stage("write", 0, total);
|
||||
for d in &empty_dirs {
|
||||
std::fs::create_dir_all(partial.join(d.trim_matches('/')))
|
||||
.map_err(|e| format!("{d}: {e}"))?;
|
||||
}
|
||||
for (path, size) in &listed {
|
||||
let dest = partial.join(path);
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
|
||||
}
|
||||
copy_file(tree, path, *size, &dest, cancel, &mut |n| {
|
||||
written += n;
|
||||
stage("write", written, total);
|
||||
})?;
|
||||
}
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
|
||||
let mut fresh = reopen()?;
|
||||
let summary = verify(
|
||||
&partial,
|
||||
&listed,
|
||||
&empty_dirs,
|
||||
&mut *fresh,
|
||||
mode,
|
||||
cancel,
|
||||
&mut |d, t| stage("verify", d, t),
|
||||
)?;
|
||||
drop(fresh);
|
||||
std::fs::remove_file(partial.join(MARKER)).map_err(|e| e.to_string())?;
|
||||
if target.exists() {
|
||||
return Err(format!(
|
||||
"{} appeared while it was written",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
std::fs::rename(&partial, target).map_err(|e| e.to_string())?;
|
||||
Ok(FolderBuilt {
|
||||
files: listed.len() as u64,
|
||||
bytes: total,
|
||||
detail: format!(
|
||||
"{} files, {} empty folders, {total} bytes, {}",
|
||||
listed.len(),
|
||||
empty_dirs.len(),
|
||||
match summary.mode {
|
||||
VerifyMode::Full => "SHA-256 checked in full".to_string(),
|
||||
VerifyMode::Fast { .. } => summary.describe(),
|
||||
}
|
||||
),
|
||||
})
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_dir_all(&partial);
|
||||
}
|
||||
match result {
|
||||
Err(_) if cancel.load(Ordering::Relaxed) => Err("cancelled".into()),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::image_build::{self, ImageFormat};
|
||||
use ps5upload_fpkg::source::{FolderSource, SourceFile};
|
||||
|
||||
fn game(root: &Path) -> PathBuf {
|
||||
let game = root.join("PPSA99999-app");
|
||||
std::fs::create_dir_all(game.join("sce_sys")).unwrap();
|
||||
std::fs::create_dir_all(game.join("data/sub")).unwrap();
|
||||
std::fs::create_dir_all(game.join("empty/deeper")).unwrap();
|
||||
let big: Vec<u8> = (0..300_000u32).map(|i| (i % 251) as u8).collect();
|
||||
std::fs::write(game.join("eboot.bin"), &big).unwrap();
|
||||
std::fs::write(
|
||||
game.join("sce_sys/param.json"),
|
||||
b"{\"titleId\":\"PPSA99999\"}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(game.join("data/sub/a.bin"), b"").unwrap();
|
||||
std::fs::write(game.join("data/zeros.bin"), vec![0u8; 200_000]).unwrap();
|
||||
game
|
||||
}
|
||||
|
||||
fn scratch(tag: &str) -> PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ps5upload-fold-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
/// Every file and empty folder under `dir`, with contents.
|
||||
fn snapshot(dir: &Path) -> (Vec<(String, Vec<u8>)>, Vec<String>) {
|
||||
let (files, empty) = ps5upload_fpkg::source::scan_tree(dir).unwrap();
|
||||
let mut out: Vec<(String, Vec<u8>)> = files
|
||||
.iter()
|
||||
.map(|f| (f.path.clone(), std::fs::read(dir.join(&f.path)).unwrap()))
|
||||
.collect();
|
||||
out.sort();
|
||||
let mut empty: Vec<String> = empty
|
||||
.iter()
|
||||
.map(|d| d.trim_matches('/').to_string())
|
||||
.collect();
|
||||
empty.sort();
|
||||
(out, empty)
|
||||
}
|
||||
|
||||
fn unpack(image: &Path, target: &Path, mode: VerifyMode) -> Result<FolderBuilt, String> {
|
||||
let mut tree = ps5upload_fpkg::source::open(image).map_err(|e| e.to_string())?;
|
||||
let path = image.to_path_buf();
|
||||
let reopen = move || ps5upload_fpkg::source::open(&path).map_err(|e| e.to_string());
|
||||
let cancel = AtomicBool::new(false);
|
||||
let mut stages: Vec<String> = Vec::new();
|
||||
let r = build(
|
||||
&mut *tree,
|
||||
&reopen,
|
||||
target,
|
||||
mode,
|
||||
&cancel,
|
||||
&mut |s, _, _| {
|
||||
if stages.last().map(String::as_str) != Some(s) {
|
||||
stages.push(s.to_string());
|
||||
}
|
||||
},
|
||||
);
|
||||
if r.is_ok() {
|
||||
assert_eq!(stages, ["write", "verify"]);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Each image Convert writes (exFAT, UFS2, PFS, and a compressed `.ffpfsc`) unpacks into a
|
||||
/// folder equal to the one it was built from, empty folders included.
|
||||
#[test]
|
||||
fn every_image_unpacks_to_the_folder_it_came_from() {
|
||||
let root = scratch("rt");
|
||||
let src = game(&root);
|
||||
let want = snapshot(&src);
|
||||
let cases = [
|
||||
(ImageFormat::Exfat, false),
|
||||
(ImageFormat::Ffpkg, false),
|
||||
(ImageFormat::Ffpfs, false),
|
||||
(ImageFormat::Exfat, true),
|
||||
];
|
||||
for (n, (format, compress)) in cases.into_iter().enumerate() {
|
||||
let (name, inner) = image_build::output_names(&src, format, compress);
|
||||
let image = root.join(&name);
|
||||
let mut tree = FolderSource::open(&src).unwrap();
|
||||
image_build::build(
|
||||
format,
|
||||
inner.as_deref(),
|
||||
&mut tree,
|
||||
&image,
|
||||
VerifyMode::Full,
|
||||
8 << 20,
|
||||
&AtomicBool::new(false),
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
for mode in [
|
||||
VerifyMode::Full,
|
||||
VerifyMode::Fast {
|
||||
seed: 7,
|
||||
seam: false,
|
||||
},
|
||||
] {
|
||||
let target = root.join(format!("out-{n}-{}", matches!(mode, VerifyMode::Full)));
|
||||
let built = unpack(&image, &target, mode).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(built.files, 4, "{name}");
|
||||
assert_eq!(snapshot(&target), want, "{name}");
|
||||
assert!(!partial_of(&target).exists());
|
||||
assert!(!target.join(MARKER).exists());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_folder_is_refused_and_left_alone() {
|
||||
let root = scratch("ex");
|
||||
let src = game(&root);
|
||||
let target = root.join("taken");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::fs::write(target.join("mine.txt"), b"keep").unwrap();
|
||||
let err = unpack(&src, &target, VerifyMode::Full).unwrap_err();
|
||||
assert!(err.contains("already exists"), "{err}");
|
||||
assert_eq!(std::fs::read(target.join("mine.txt")).unwrap(), b"keep");
|
||||
assert!(!partial_of(&target).exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_leaves_no_partial_folder() {
|
||||
let root = scratch("cx");
|
||||
let src = game(&root);
|
||||
let target = root.join("out");
|
||||
let mut tree = FolderSource::open(&src).unwrap();
|
||||
let reopen = || -> Result<Box<dyn SourceTree>, String> { unreachable!() };
|
||||
let cancel = AtomicBool::new(false);
|
||||
let err = build(
|
||||
&mut tree,
|
||||
&reopen,
|
||||
&target,
|
||||
VerifyMode::Full,
|
||||
&cancel,
|
||||
&mut |_, done, _| {
|
||||
if done > 0 {
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, "cancelled");
|
||||
assert!(!target.exists() && !partial_of(&target).exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A source tree with whatever paths a test wants, every file a few bytes long.
|
||||
struct Listed(Vec<SourceFile>, Vec<String>);
|
||||
|
||||
impl SourceTree for Listed {
|
||||
fn files(&self) -> &[SourceFile] {
|
||||
&self.0
|
||||
}
|
||||
fn empty_dirs(&self) -> &[String] {
|
||||
&self.1
|
||||
}
|
||||
fn read(&mut self, _: &str) -> ps5upload_fpkg::Result<Vec<u8>> {
|
||||
Ok(b"abc".to_vec())
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"listed".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn listed(paths: &[&str]) -> Listed {
|
||||
Listed(
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| SourceFile {
|
||||
path: p.to_string(),
|
||||
size: 3,
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A path that would land outside the folder, or a name the drive cannot hold, fails the
|
||||
/// build naming the file, before anything is written (not even the `.partial`).
|
||||
#[test]
|
||||
fn bad_names_fail_before_anything_is_written() {
|
||||
let root = scratch("bad");
|
||||
let target = root.join("out");
|
||||
let reopen = || -> Result<Box<dyn SourceTree>, String> { unreachable!() };
|
||||
let mut bad = vec![
|
||||
"ok.bin|../evil.bin",
|
||||
"ok.bin|/abs.bin",
|
||||
"ok.bin|a//b.bin",
|
||||
"a\0b",
|
||||
];
|
||||
if cfg!(windows) {
|
||||
bad.extend(["ok.bin|CON.txt", "ok.bin|what?.bin", "ok.bin|trail."]);
|
||||
}
|
||||
for case in bad {
|
||||
let paths: Vec<&str> = case.split('|').collect();
|
||||
let mut tree = listed(&paths);
|
||||
let err = build(
|
||||
&mut tree,
|
||||
&reopen,
|
||||
&target,
|
||||
VerifyMode::Full,
|
||||
&AtomicBool::new(false),
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
let named = paths.last().unwrap();
|
||||
assert!(err.starts_with(named), "{case}: {err}");
|
||||
assert!(!target.exists() && !partial_of(&target).exists(), "{case}");
|
||||
}
|
||||
// A file that is also a folder, and the marker's own name.
|
||||
for paths in [&["a", "a/b"][..], &[MARKER][..]] {
|
||||
let mut tree = listed(paths);
|
||||
assert!(build(
|
||||
&mut tree,
|
||||
&reopen,
|
||||
&target,
|
||||
VerifyMode::Full,
|
||||
&AtomicBool::new(false),
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.is_err());
|
||||
assert!(!partial_of(&target).exists());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_names_are_checked_on_every_platform() {
|
||||
assert!(path_problem("sce_sys/param.json", true).is_none());
|
||||
assert!(path_problem("a/CON", true).is_some());
|
||||
assert!(path_problem("lpt1.txt", true).is_some());
|
||||
assert!(path_problem("COM0", true).is_none());
|
||||
assert!(path_problem("CONSOLE.txt", true).is_none());
|
||||
assert!(path_problem("C:/x", true).is_some());
|
||||
assert!(path_problem("a:b", true).is_some());
|
||||
assert!(path_problem("a:b", false).is_none());
|
||||
assert!(path_problem("dir /x", true).is_some());
|
||||
assert!(path_problem("..", false).is_some());
|
||||
assert!(path_problem("a/./b", false).is_some());
|
||||
assert!(name_problem("Game").is_none());
|
||||
assert!(name_problem("a/b").is_some());
|
||||
assert!(name_problem("..").is_some());
|
||||
assert!(name_problem("x.partial").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_marked_partial_folder_is_ours_and_swept() {
|
||||
let root = scratch("own");
|
||||
let ours = root.join("A.partial");
|
||||
let theirs = root.join("B.partial");
|
||||
std::fs::create_dir_all(&ours).unwrap();
|
||||
std::fs::create_dir_all(&theirs).unwrap();
|
||||
std::fs::write(ours.join(MARKER), b"").unwrap();
|
||||
assert!(is_our_partial_dir(&ours));
|
||||
assert!(!is_our_partial_dir(&theirs));
|
||||
// The output folder's sweep removes ours, unless a running build is writing it.
|
||||
let active = root.join("C.partial");
|
||||
std::fs::create_dir_all(&active).unwrap();
|
||||
std::fs::write(active.join(MARKER), b"").unwrap();
|
||||
let writing = crate::fpkg_api::Writing::claim(active.clone()).unwrap();
|
||||
std::fs::create_dir_all(&active).unwrap();
|
||||
std::fs::write(active.join(MARKER), b"").unwrap();
|
||||
crate::fpkg_api::sweep_output_leftovers(&root);
|
||||
assert!(!ours.exists());
|
||||
assert!(theirs.is_dir() && active.is_dir());
|
||||
// A second build of the same name is refused while the first runs.
|
||||
assert!(crate::fpkg_api::Writing::claim(active.clone()).is_none());
|
||||
drop(writing);
|
||||
crate::fpkg_api::sweep_output_leftovers(&root);
|
||||
assert!(!active.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -1151,6 +1151,267 @@ pub(crate) async fn exfat_build_handler(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct FolderBuildReq {
|
||||
/// A game image (`.exfat`, `.ffpkg`/`.ufs2`, `.ffpfs`, `.ffpfsc`) or a debug-key `.pkg`,
|
||||
/// on this computer, a saved server (`remote://`) or a console (`ps5://`).
|
||||
source: String,
|
||||
#[serde(default)]
|
||||
output_dir: Option<String>,
|
||||
/// The folder's name; the source file's name without its extension when absent.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Compare every file by SHA-256. Off (the default): every size, and a seeded sample.
|
||||
#[serde(default)]
|
||||
full_verify: bool,
|
||||
}
|
||||
|
||||
/// POST /api/folder/build — unpack a game image or a debug-key package into a plain folder
|
||||
/// (#469). Runs as a job like an image build: stages `write` and `verify`, the folder written
|
||||
/// as `<name>.partial/` and renamed only once it reads back. Done's `dest` is the folder.
|
||||
pub(crate) async fn folder_build_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<FolderBuildReq>,
|
||||
) -> impl IntoResponse {
|
||||
use crate::folder_build;
|
||||
let source = resolve_engine_path(&req.source);
|
||||
let remote = ps5upload_fpkg::remote_source::is_remote(&source);
|
||||
if !remote && tokio::fs::metadata(&source).await.is_ok_and(|m| m.is_dir()) {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("{} is already a folder", source.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
// On a server or console only the name tells: a folder there has no image extension.
|
||||
let named_as_image = if remote {
|
||||
crate::image_build::has_image_extension(&source)
|
||||
} else {
|
||||
crate::image_build::is_image_file(&source)
|
||||
};
|
||||
if !named_as_image {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("{} is not a game image or package", source.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let name = match req.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) => {
|
||||
if let Some(why) = folder_build::name_problem(n) {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("the folder name {n:?} {why}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
n.to_string()
|
||||
}
|
||||
None => crate::image_build::stem_of(&source),
|
||||
};
|
||||
let out_dir = output_dir(req.output_dir.as_deref());
|
||||
let target = out_dir.join(&name);
|
||||
if tokio::fs::try_exists(&target).await.unwrap_or(false) {
|
||||
return json_err(
|
||||
StatusCode::CONFLICT,
|
||||
format!("{} already exists", target.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
// A `<name>.partial` that is not one of ours is the user's: never cleared, never used.
|
||||
let partial = folder_build::partial_of(&target);
|
||||
if tokio::fs::try_exists(&partial).await.unwrap_or(false)
|
||||
&& !folder_build::is_our_partial_dir(&partial)
|
||||
{
|
||||
return json_err(
|
||||
StatusCode::CONFLICT,
|
||||
format!("{} already exists", partial.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = tokio::fs::create_dir_all(&out_dir).await {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("{}: {e}", out_dir.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let scan_source = source.clone();
|
||||
let scanned = tokio::task::spawn_blocking(move || {
|
||||
ps5upload_fpkg::source::open(&scan_source).map_err(|e| e.to_string())
|
||||
})
|
||||
.await;
|
||||
let tree = match scanned {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return json_err(StatusCode::BAD_REQUEST, e).into_response(),
|
||||
Err(e) => {
|
||||
return json_err(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response()
|
||||
}
|
||||
};
|
||||
if tree.files().is_empty() {
|
||||
return json_err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("{} has no files in it", source.display()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let total: u64 = tree.files().iter().map(|f| f.size).sum();
|
||||
if let Some(why) =
|
||||
build::free_bytes(&out_dir).and_then(|free| folder_build::shortfall(free, total, &out_dir))
|
||||
{
|
||||
return json_err(StatusCode::BAD_REQUEST, why).into_response();
|
||||
}
|
||||
let Some(writing) = Writing::claim_off_thread(partial.clone()).await else {
|
||||
return already_building(&partial);
|
||||
};
|
||||
let mode = ps5upload_fpkg::sample::VerifyMode::from_full(req.full_verify, false);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let cancel = register_transfer_cancel(job_id);
|
||||
let bytes = Arc::new(AtomicU64::new(0));
|
||||
let started_at_ms = now_ms();
|
||||
set_job(
|
||||
&state.jobs,
|
||||
&state.events_tx,
|
||||
job_id,
|
||||
JobState::Running {
|
||||
stage: None,
|
||||
started_at_ms,
|
||||
bytes_sent: 0,
|
||||
total_bytes: total,
|
||||
files: Default::default(),
|
||||
skipped_files: 0,
|
||||
skipped_bytes: 0,
|
||||
files_processing: 0,
|
||||
files_finalized: 0,
|
||||
files_finalizing_total: 0,
|
||||
bytes_finalized: 0,
|
||||
},
|
||||
);
|
||||
let jobs = state.jobs.clone();
|
||||
let events_tx = state.events_tx.clone();
|
||||
let tick_bytes = bytes.clone();
|
||||
let current: Arc<std::sync::Mutex<Option<JobStage>>> = Arc::default();
|
||||
let tick_stage = current.clone();
|
||||
let ticker = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let mut g = jobs.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match g.get_mut(&job_id) {
|
||||
Some(JobState::Running {
|
||||
bytes_sent, stage, ..
|
||||
}) => {
|
||||
*bytes_sent = tick_bytes.load(Ordering::Relaxed);
|
||||
*stage = tick_stage.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let state = (events_tx.receiver_count() > 0)
|
||||
.then(|| g.get(&job_id).map(|st| serde_json::json!(st)))
|
||||
.flatten();
|
||||
drop(g);
|
||||
if let Some(state) = state {
|
||||
let msg = serde_json::json!({ "job_id": job_id.to_string(), "job": state });
|
||||
let _ = events_tx.send(msg.to_string());
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let state_for_job = state.clone();
|
||||
let ticker = JobTicker(ticker.abort_handle());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut fail = JobFailOnDropGuard::new(
|
||||
state_for_job.jobs.clone(),
|
||||
state_for_job.events_tx.clone(),
|
||||
job_id,
|
||||
started_at_ms,
|
||||
);
|
||||
sweep_output_leftovers(&out_dir);
|
||||
let mut tree = tree;
|
||||
const STAGES: [&str; 2] = ["write", "verify"];
|
||||
let mut report = |id: &str, done: u64, total: u64| {
|
||||
if id == "write" {
|
||||
bytes.store(done, Ordering::Relaxed);
|
||||
}
|
||||
let index = STAGES.iter().position(|s| *s == id).unwrap_or(0) as u32;
|
||||
*current.lock().unwrap_or_else(|e| e.into_inner()) = Some(JobStage {
|
||||
id: id.to_string(),
|
||||
index,
|
||||
count: STAGES.len() as u32,
|
||||
done,
|
||||
total,
|
||||
});
|
||||
};
|
||||
let reopen_source = source.clone();
|
||||
let reopen =
|
||||
move || ps5upload_fpkg::source::open(&reopen_source).map_err(|e| e.to_string());
|
||||
let outcome = folder_build::build(&mut *tree, &reopen, &target, mode, &cancel, &mut report);
|
||||
drop(tree);
|
||||
drop(writing);
|
||||
if outcome.is_err() {
|
||||
sweep_output_leftovers(&out_dir);
|
||||
}
|
||||
let completed_at_ms = now_ms();
|
||||
drop(ticker);
|
||||
match outcome {
|
||||
Ok(built) => {
|
||||
crate::engine_log::record(
|
||||
"info",
|
||||
format!(
|
||||
"folder: {} -> {} ({}, read back)",
|
||||
source.display(),
|
||||
target.display(),
|
||||
built.detail
|
||||
),
|
||||
);
|
||||
set_job(
|
||||
&state_for_job.jobs,
|
||||
&state_for_job.events_tx,
|
||||
job_id,
|
||||
JobState::Done {
|
||||
started_at_ms,
|
||||
completed_at_ms,
|
||||
elapsed_ms: completed_at_ms.saturating_sub(started_at_ms),
|
||||
tx_id_hex: String::new(),
|
||||
bytes_sent: built.bytes,
|
||||
dest: target.display().to_string(),
|
||||
files_sent: built.files,
|
||||
skipped_files: 0,
|
||||
skipped_bytes: 0,
|
||||
commit_ack: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
crate::engine_log::record("warn", format!("folder: build failed: {error}"));
|
||||
set_job(
|
||||
&state_for_job.jobs,
|
||||
&state_for_job.events_tx,
|
||||
job_id,
|
||||
JobState::Failed {
|
||||
started_at_ms,
|
||||
completed_at_ms,
|
||||
elapsed_ms: completed_at_ms.saturating_sub(started_at_ms),
|
||||
error,
|
||||
error_reason: None,
|
||||
error_detail: None,
|
||||
error_console: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
fail.mark_succeeded();
|
||||
});
|
||||
|
||||
(
|
||||
StatusCode::ACCEPTED,
|
||||
Json(JobCreated {
|
||||
job_id: job_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Stops a job's progress ticker when dropped: when the work ends, a panic included.
|
||||
struct JobTicker(tokio::task::AbortHandle);
|
||||
|
||||
@@ -1283,8 +1544,12 @@ pub(crate) fn sweep_output_leftovers(out: &Path) {
|
||||
let name = e.file_name().to_string_lossy().to_ascii_lowercase();
|
||||
match e.file_type() {
|
||||
Ok(t) if t.is_file() => is_our_partial(&name),
|
||||
// An LZ4 image build's pack volumes, `<name>.exfat.lz4spool/`.
|
||||
Ok(t) if t.is_dir() => name.ends_with(".exfat.lz4spool"),
|
||||
// An LZ4 image build's pack volumes, `<name>.exfat.lz4spool/`, and a
|
||||
// folder build's unfinished folder (marked, so a user's own is kept).
|
||||
Ok(t) if t.is_dir() => {
|
||||
name.ends_with(".exfat.lz4spool")
|
||||
|| crate::folder_build::is_our_partial_dir(&e.path())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,18 +59,23 @@ impl ImageFormat {
|
||||
pub(crate) const PFS_NAME_MAX: usize = 63;
|
||||
|
||||
/// A safe file stem from a game folder's name.
|
||||
/// Image files a build may read as its source (an .exfat into a .ffpkg, say).
|
||||
/// Image files a build may read as its source (an .exfat into a .ffpkg, say), and a package
|
||||
/// made with the debug key, which `source::open` reads like an image.
|
||||
pub(crate) fn is_image_file(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
&& path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||
matches!(
|
||||
e.to_ascii_lowercase().as_str(),
|
||||
"exfat" | "ffpkg" | "ufs2" | "ffpfs" | "ffpfsc"
|
||||
)
|
||||
})
|
||||
path.is_file() && has_image_extension(path)
|
||||
}
|
||||
|
||||
fn stem_of(source: &Path) -> String {
|
||||
/// [`is_image_file`]'s name check alone, for a path on a server or console.
|
||||
pub(crate) fn has_image_extension(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||
matches!(
|
||||
e.to_ascii_lowercase().as_str(),
|
||||
"exfat" | "ffpkg" | "ufs2" | "ffpfs" | "ffpfsc" | "pkg"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn stem_of(source: &Path) -> String {
|
||||
let name = source
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
@@ -81,7 +86,7 @@ fn stem_of(source: &Path) -> String {
|
||||
if !base.is_empty()
|
||||
&& matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"exfat" | "ffpkg" | "ufs2" | "ffpfs" | "ffpfsc"
|
||||
"exfat" | "ffpkg" | "ufs2" | "ffpfs" | "ffpfsc" | "pkg"
|
||||
) =>
|
||||
{
|
||||
base.to_string()
|
||||
|
||||
@@ -46,6 +46,7 @@ mod elfldr_guard;
|
||||
mod engine_log;
|
||||
mod event_journal;
|
||||
mod fakelibs_api;
|
||||
mod folder_build;
|
||||
mod fpkg_api;
|
||||
mod fpkg_firmware;
|
||||
mod games_api;
|
||||
@@ -10216,6 +10217,7 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
|
||||
post(fpkg_api::ffpfsc_compress_handler),
|
||||
)
|
||||
.route("/api/exfat/build", post(fpkg_api::exfat_build_handler))
|
||||
.route("/api/folder/build", post(fpkg_api::folder_build_handler))
|
||||
.route(
|
||||
"/api/collection/settings",
|
||||
get(collection_api::get_settings).put(collection_api::put_settings),
|
||||
|
||||
Reference in new issue
Block a user