mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 00:48:32 +02:00
fix: stream large required imports on mobile
This commit is contained in:
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::pickFile(const char *kind) const
|
||||
bool System::pickFile(const char *kind, const char *destination) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
const char *dest = "picked_rom.gb";
|
||||
@@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
dest = (destination != nullptr && destination[0] != '\0')
|
||||
? destination : "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
|
||||
@@ -116,7 +116,7 @@ public:
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind));
|
||||
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind, destination));
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlarmManager;
|
||||
@@ -111,6 +112,8 @@ public class GameActivity extends SDLActivity {
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Written after a direct required-import copy has been fully published.
|
||||
private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag";
|
||||
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
||||
@@ -576,6 +579,21 @@ public class GameActivity extends SDLActivity {
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||
* picked_required_import.bin)
|
||||
*/
|
||||
private static boolean isDirectRequiredDestination(String relative) {
|
||||
if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false;
|
||||
String normalized = relative.replace('\\', '/');
|
||||
if (!normalized.startsWith("mods/")) return false;
|
||||
int marker = normalized.indexOf("/baseroms/");
|
||||
if (marker <= "mods/".length() || marker + "/baseroms/".length() >= normalized.length()) {
|
||||
return false;
|
||||
}
|
||||
return !normalized.contains("//")
|
||||
&& !normalized.equals("..")
|
||||
&& !normalized.startsWith("../")
|
||||
&& !normalized.contains("/../")
|
||||
&& !normalized.endsWith("/..");
|
||||
}
|
||||
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
|
||||
// onActivityResult copies the pick there, not into a recomputed
|
||||
// (possibly different-volume) root (#604, #839).
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
// Basename destinations keep the historical ROM/mod/save staging path.
|
||||
// A nested destination is accepted only for an engine-generated mod
|
||||
// baseroms path, then canonicalized beneath LOVE's mounted save root.
|
||||
String normalizedDest = destFilename.replace('\\', '/');
|
||||
boolean nested = normalizedDest.indexOf('/') >= 0;
|
||||
if (nested && !isDirectRequiredDestination(normalizedDest)) {
|
||||
Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
File rootCanonical = self.saveIdentityDir().getCanonicalFile();
|
||||
File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile();
|
||||
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||
if (destCanonical.equals(rootCanonical)
|
||||
|| !destCanonical.getPath().startsWith(rootPrefix)) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not validate picker dest: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pendingPickFilename = destFilename;
|
||||
self.pendingPickFilename = normalizedDest;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
@@ -1277,13 +1311,7 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
private void writeSaveDirFlag(String name, String body) {
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
|
||||
fos.write(body.getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
writeFlagFile(saveIdentityDir(), name, body);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "could not create " + destDir);
|
||||
return;
|
||||
}
|
||||
String destName = pendingPickFilename != null
|
||||
final String destName = pendingPickFilename != null
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
final boolean directRequired = isDirectRequiredDestination(destName);
|
||||
final File destFile;
|
||||
try {
|
||||
File rootCanonical = destDir.getCanonicalFile();
|
||||
destFile = new File(rootCanonical, destName).getCanonicalFile();
|
||||
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||
if (destFile.equals(rootCanonical)
|
||||
|| !destFile.getPath().startsWith(rootPrefix)
|
||||
|| (destName.indexOf('/') >= 0 && !directRequired)) {
|
||||
Log.d("GameActivity", "refusing unsafe result dest: " + destName);
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not validate result dest: " + e.getMessage());
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
||||
@@ -1534,15 +1579,110 @@ public class GameActivity extends SDLActivity {
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
final InputStream pickedSource = source;
|
||||
final File pickedRoot = destDir;
|
||||
if (directRequired) {
|
||||
// Optical-disc-sized imports must not block Android's UI thread and
|
||||
// must not create a second picked_required_import.bin copy.
|
||||
new Thread(new Runnable() {
|
||||
@Override public void run() {
|
||||
PickCopyResult result = copyRequiredImport(pickedSource, destFile);
|
||||
if (!result.ok) {
|
||||
writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
String marker = "v1\n" + destName + "\n" + result.md5 + "\n"
|
||||
+ Long.toString(result.bytes) + "\n";
|
||||
writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker);
|
||||
}
|
||||
}, "gen1recomp-required-import").start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!copyAssetFile(pickedSource, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
// A truncated pick would only fail verification later, so drop it
|
||||
// and report instead.
|
||||
destFile.delete();
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PickCopyResult {
|
||||
boolean ok = false;
|
||||
long bytes = 0;
|
||||
String md5 = "";
|
||||
}
|
||||
|
||||
private static String hex(byte[] bytes) {
|
||||
StringBuilder out = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) out.append(String.format(Locale.US, "%02x", b & 0xff));
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static void writeFlagFile(File dir, String name, String body) {
|
||||
try {
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) return;
|
||||
File tmp = new File(dir, name + ".tmp");
|
||||
File dest = new File(dir, name);
|
||||
FileOutputStream out = new FileOutputStream(tmp, false);
|
||||
out.write(body.getBytes("UTF-8"));
|
||||
out.getFD().sync();
|
||||
out.close();
|
||||
if (dest.exists() && !dest.delete()) { tmp.delete(); return; }
|
||||
if (!tmp.renameTo(dest)) tmp.delete();
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static PickCopyResult copyRequiredImport(InputStream source, File destination) {
|
||||
PickCopyResult result = new PickCopyResult();
|
||||
File parent = destination.getParentFile();
|
||||
File partial = new File(destination.getPath() + ".part");
|
||||
BufferedInputStream in = null;
|
||||
BufferedOutputStream out = null;
|
||||
FileOutputStream rawOut = null;
|
||||
try {
|
||||
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) return result;
|
||||
if (partial.exists() && !partial.delete()) return result;
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
in = new BufferedInputStream(source, 1024 * 1024);
|
||||
rawOut = new FileOutputStream(partial, false);
|
||||
out = new BufferedOutputStream(rawOut, 1024 * 1024);
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
int n;
|
||||
long total = 0;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
if (n == 0) continue;
|
||||
out.write(buf, 0, n);
|
||||
md5.update(buf, 0, n);
|
||||
total += n;
|
||||
}
|
||||
out.flush();
|
||||
rawOut.getFD().sync();
|
||||
out.close(); out = null; rawOut = null;
|
||||
in.close(); in = null;
|
||||
|
||||
// Publish only a complete same-directory file. Validation still
|
||||
// happens in Lua against the manifest before a receipt is written.
|
||||
if (destination.exists() && !destination.delete()) return result;
|
||||
if (!partial.renameTo(destination)) return result;
|
||||
result.ok = true;
|
||||
result.bytes = total;
|
||||
result.md5 = hex(md5.digest());
|
||||
Log.d("GameActivity", "direct required import copied " + total
|
||||
+ " bytes to " + destination);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "direct required import failed: " + e.getMessage());
|
||||
return result;
|
||||
} finally {
|
||||
try { if (in != null) in.close(); } catch (IOException ignored) {}
|
||||
try { if (out != null) out.close(); } catch (IOException ignored) {}
|
||||
try { if (rawOut != null) rawOut.close(); } catch (IOException ignored) {}
|
||||
if (!result.ok && partial.exists()) partial.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a given file from the assets folder to the destination.
|
||||
*
|
||||
@@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity {
|
||||
assert (source != null && destination != null);
|
||||
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
chunk_read = source.read(buf);
|
||||
do {
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
while ((chunk_read = source.read(buf)) != -1) {
|
||||
if (chunk_read == 0) continue;
|
||||
destination.write(buf, 0, chunk_read);
|
||||
bytes_written += chunk_read;
|
||||
chunk_read = source.read(buf);
|
||||
} while (chunk_read != -1);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "Copying failed:" + e.getMessage());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import CryptoKit
|
||||
|
||||
@objc(GRPickerBridge)
|
||||
public final class GRPickerBridge: NSObject {
|
||||
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
return presentPicker(kind: kind, saveDir: saveDir, destination: nil)
|
||||
}
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:destination:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?,
|
||||
destination: UnsafePointer<CChar>?) -> Bool {
|
||||
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
|
||||
let requestedDestination = destination.map { String(cString: $0) }
|
||||
let destName: String
|
||||
var types: [UTType] = []
|
||||
switch kindStr {
|
||||
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
if let requestedDestination,
|
||||
isDirectRequiredDestination(requestedDestination),
|
||||
safeDestination(in: dir, relative: requestedDestination) != nil {
|
||||
destName = requestedDestination
|
||||
} else {
|
||||
destName = "picked_required_import.bin"
|
||||
}
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
@@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject {
|
||||
types.append(.data)
|
||||
if !types.contains(.item) { types.append(.item) }
|
||||
|
||||
let directRequired = kindStr == "required_import"
|
||||
&& destName != "picked_required_import.bin"
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||
asCopy: true)
|
||||
asCopy: !directRequired)
|
||||
picker.allowsMultipleSelection = false
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard let src = urls.first else { return }
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
if directRequired {
|
||||
copyRequiredItemAsync(at: src, into: dir, relative: destName)
|
||||
} else {
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
}
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
|
||||
try? fm.moveItem(at: item, to: target)
|
||||
}
|
||||
|
||||
private static func isDirectRequiredDestination(_ relative: String) -> Bool {
|
||||
let normalized = relative.replacingOccurrences(of: "\\", with: "/")
|
||||
guard normalized.hasPrefix("mods/"),
|
||||
let range = normalized.range(of: "/baseroms/"),
|
||||
range.lowerBound > normalized.index(normalized.startIndex, offsetBy: 5),
|
||||
range.upperBound < normalized.endIndex else { return false }
|
||||
return !normalized.hasPrefix("/")
|
||||
&& !normalized.contains("//")
|
||||
&& !normalized.contains("/../")
|
||||
&& !normalized.hasSuffix("/..")
|
||||
}
|
||||
|
||||
private static func safeDestination(in root: URL, relative: String) -> URL? {
|
||||
guard isDirectRequiredDestination(relative) else { return nil }
|
||||
let rootURL = root.standardizedFileURL
|
||||
let candidate = rootURL.appendingPathComponent(relative).standardizedFileURL
|
||||
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
|
||||
guard candidate.path.hasPrefix(rootPath) else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private static func writeFlag(in dir: URL, name: String, body: String) {
|
||||
try? body.data(using: .utf8)?.write(to: dir.appendingPathComponent(name),
|
||||
options: .atomic)
|
||||
}
|
||||
|
||||
private static func copyRequiredItemAsync(at src: URL, into dir: URL,
|
||||
relative: String) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
guard let dest = safeDestination(in: dir, relative: relative) else {
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
return
|
||||
}
|
||||
let fm = FileManager.default
|
||||
ensureDirectory(dest.deletingLastPathComponent())
|
||||
let partial = URL(fileURLWithPath: dest.path + ".part")
|
||||
try? fm.removeItem(at: partial)
|
||||
guard fm.createFile(atPath: partial.path, contents: nil) else {
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
return
|
||||
}
|
||||
|
||||
var hasher = Insecure.MD5()
|
||||
var total: UInt64 = 0
|
||||
do {
|
||||
let input = try FileHandle(forReadingFrom: src)
|
||||
let output = try FileHandle(forWritingTo: partial)
|
||||
defer {
|
||||
try? input.close()
|
||||
try? output.close()
|
||||
}
|
||||
while true {
|
||||
let data = input.readData(ofLength: 1024 * 1024)
|
||||
if data.isEmpty { break }
|
||||
hasher.update(data: data)
|
||||
output.write(data)
|
||||
total += UInt64(data.count)
|
||||
}
|
||||
output.synchronizeFile()
|
||||
try? fm.removeItem(at: dest)
|
||||
try fm.moveItem(at: partial, to: dest)
|
||||
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
let marker = "v1\n" + relative + "\n" + digest + "\n"
|
||||
+ String(total) + "\n"
|
||||
writeFlag(in: dir, name: "pick_complete.flag", body: marker)
|
||||
NSLog("GRPickerBridge: direct required import delivered %llu bytes", total)
|
||||
} catch {
|
||||
try? fm.removeItem(at: partial)
|
||||
NSLog("GRPickerBridge: direct required import failed: \(error)")
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
|
||||
let report = "Could not copy \(src.lastPathComponent): " +
|
||||
error.localizedDescription
|
||||
try? report.data(using: .utf8)?
|
||||
.write(to: dir.appendingPathComponent("pick_error.txt"))
|
||||
.write(to: dir.appendingPathComponent("pick_error.flag"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,20 @@ static int gr_callBridge(lua_State *L, const char *className,
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, "rom");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRPick)(Class, SEL, const char *, const char *,
|
||||
const char *);
|
||||
signed char ok = ((GRPick)objc_msgSend)(
|
||||
cls, sel_registerName("presentPickerWithKind:saveDir:destination:"),
|
||||
kind, gr_saveDirectory(), destination);
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||
|
||||
Reference in New Issue
Block a user