mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Merge pull request #1791 from HighDrexler/fix/mobile-required-import-streaming-v2
Fix/mobile required import streaming v2
This commit is contained in:
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool System::pickFile(const char *kind) const
|
bool System::pickFile(const char *kind, const char *destination) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
const char *dest = "picked_rom.gb";
|
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)
|
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||||
dest = "picked_save.sav";
|
dest = "picked_save.sav";
|
||||||
else if (strcmp(kind, "required_import") == 0)
|
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)
|
else if (strcmp(kind, "rom") == 0)
|
||||||
dest = "picked_rom.gb";
|
dest = "picked_rom.gb";
|
||||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ public:
|
|||||||
* "required_import" -> picked_required_import.bin.
|
* "required_import" -> picked_required_import.bin.
|
||||||
* @return Whether the picker was shown.
|
* @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;
|
virtual const char *pickFileKinds() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
|
|||||||
int w_pickFile(lua_State *L)
|
int w_pickFile(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *kind = luaL_optstring(L, 1, nullptr);
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.app.AlarmManager;
|
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
|
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
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
|
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||||
// consumed by the Pokéwalker mod, same contract as the iOS
|
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||||
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
// 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_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||||
* picked_required_import.bin)
|
* 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. */
|
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean showFilePicker(String destFilename) {
|
public static boolean showFilePicker(String destFilename) {
|
||||||
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
|
|||||||
// onActivityResult copies the pick there, not into a recomputed
|
// onActivityResult copies the pick there, not into a recomputed
|
||||||
// (possibly different-volume) root (#604, #839).
|
// (possibly different-volume) root (#604, #839).
|
||||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||||
// Reject path separators so a hostile JNI caller cannot escape the
|
// Basename destinations keep the historical ROM/mod/save staging path.
|
||||||
// save identity directory.
|
// A nested destination is accepted only for an engine-generated mod
|
||||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
// baseroms path, then canonicalized beneath LOVE's mounted save root.
|
||||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pendingPickFilename = destFilename;
|
self.pendingPickFilename = normalizedDest;
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
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. */
|
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||||
private void writeSaveDirFlag(String name, String body) {
|
private void writeSaveDirFlag(String name, String body) {
|
||||||
try {
|
writeFlagFile(saveIdentityDir(), name, body);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "could not create " + destDir);
|
Log.d("GameActivity", "could not create " + destDir);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String destName = pendingPickFilename != null
|
final String destName = pendingPickFilename != null
|
||||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
? 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
|
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
// 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);
|
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||||
return;
|
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);
|
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();
|
destFile.delete();
|
||||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
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.
|
* 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);
|
assert (source != null && destination != null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] buf = new byte[1024];
|
byte[] buf = new byte[1024 * 1024];
|
||||||
chunk_read = source.read(buf);
|
while ((chunk_read = source.read(buf)) != -1) {
|
||||||
do {
|
if (chunk_read == 0) continue;
|
||||||
destination.write(buf, 0, chunk_read);
|
destination.write(buf, 0, chunk_read);
|
||||||
bytes_written += chunk_read;
|
bytes_written += chunk_read;
|
||||||
chunk_read = source.read(buf);
|
}
|
||||||
} while (chunk_read != -1);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.d("GameActivity", "Copying failed:" + e.getMessage());
|
Log.d("GameActivity", "Copying failed:" + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
@objc(GRPickerBridge)
|
@objc(GRPickerBridge)
|
||||||
public final class GRPickerBridge: NSObject {
|
public final class GRPickerBridge: NSObject {
|
||||||
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
|
|||||||
@objc(presentPickerWithKind:saveDir:)
|
@objc(presentPickerWithKind:saveDir:)
|
||||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
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"
|
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||||
|
|
||||||
|
let requestedDestination = destination.map { String(cString: $0) }
|
||||||
let destName: String
|
let destName: String
|
||||||
var types: [UTType] = []
|
var types: [UTType] = []
|
||||||
switch kindStr {
|
switch kindStr {
|
||||||
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
|
|||||||
case "sav":
|
case "sav":
|
||||||
destName = "picked_save.sav"
|
destName = "picked_save.sav"
|
||||||
case "required_import":
|
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 --
|
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
// 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
|
// 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)
|
types.append(.data)
|
||||||
if !types.contains(.item) { types.append(.item) }
|
if !types.contains(.item) { types.append(.item) }
|
||||||
|
|
||||||
|
let directRequired = kindStr == "required_import"
|
||||||
|
&& destName != "picked_required_import.bin"
|
||||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||||
asCopy: true)
|
asCopy: !directRequired)
|
||||||
picker.allowsMultipleSelection = false
|
picker.allowsMultipleSelection = false
|
||||||
let delegate = PickerDelegate { urls in
|
let delegate = PickerDelegate { urls in
|
||||||
guard let src = urls.first else { return }
|
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)
|
return present(picker, with: delegate)
|
||||||
}
|
}
|
||||||
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
|
|||||||
try? fm.moveItem(at: item, to: target)
|
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) {
|
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||||
let scoped = src.startAccessingSecurityScopedResource()
|
let scoped = src.startAccessingSecurityScopedResource()
|
||||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||||
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
|
|||||||
let report = "Could not copy \(src.lastPathComponent): " +
|
let report = "Could not copy \(src.lastPathComponent): " +
|
||||||
error.localizedDescription
|
error.localizedDescription
|
||||||
try? report.data(using: .utf8)?
|
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)
|
int w_pickFile(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *kind = luaL_optstring(L, 1, "rom");
|
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
|
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||||
|
|||||||
+150
-2
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
|
|||||||
local RomImporter = {}
|
local RomImporter = {}
|
||||||
RomImporter.__index = RomImporter
|
RomImporter.__index = RomImporter
|
||||||
|
|
||||||
|
local PICK_COMPLETE_FILENAME = "pick_complete.flag"
|
||||||
|
local finishDirectRequiredImport
|
||||||
|
|
||||||
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
|
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
|
||||||
-- builds that compiled one (Android, and iOS builds patched by
|
-- builds that compiled one (Android, and iOS builds patched by
|
||||||
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
|
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
|
||||||
@@ -1582,8 +1585,11 @@ function RomImporter:focus(f)
|
|||||||
.. love.filesystem.getSaveDirectory()
|
.. love.filesystem.getSaveDirectory()
|
||||||
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
||||||
and self.pickerPendingKind == "required_import"
|
and self.pickerPendingKind == "required_import"
|
||||||
if pickError:find("picked_required_import", 1, true)
|
if self.pickerPendingKind == "required_import"
|
||||||
|
or pickError:find("picked_required_import", 1, true)
|
||||||
or pickError:find("picked_stadium", 1, true)
|
or pickError:find("picked_stadium", 1, true)
|
||||||
|
or pickError:find("/baseroms/", 1, true)
|
||||||
|
or pickError:find("\\baseroms\\", 1, true)
|
||||||
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
|
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
|
||||||
self.modNotice = { ok = false, text = text }
|
self.modNotice = { ok = false, text = text }
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
@@ -1606,6 +1612,22 @@ function RomImporter:focus(f)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Current mobile bridge: the native picker has already streamed a raw
|
||||||
|
-- dependency into mods/<id>/baseroms and published its digest/size marker.
|
||||||
|
local completedRequired = love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file")
|
||||||
|
and love.filesystem.read(PICK_COMPLETE_FILENAME)
|
||||||
|
if completedRequired then
|
||||||
|
love.filesystem.remove(PICK_COMPLETE_FILENAME)
|
||||||
|
self.pickPending = nil
|
||||||
|
finishDirectRequiredImport(self, completedRequired)
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self.pickerPendingModId = nil
|
||||||
|
self.pickerPendingImportId = nil
|
||||||
|
self.requiredImportLegacyRomPick = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local requiredName = findPendingRequiredImport(self)
|
local requiredName = findPendingRequiredImport(self)
|
||||||
if requiredName then
|
if requiredName then
|
||||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||||
@@ -2059,6 +2081,122 @@ local function requiredImportNotice(self, modId, importId, text)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Current Android/iOS bridges can stream a raw required import directly from
|
||||||
|
-- the system document provider into its engine-owned baseroms destination.
|
||||||
|
-- The bridge hashes/counts the same bytes and then publishes this tiny marker:
|
||||||
|
-- v1\n<relative destination>\n<md5>\n<byte count>\n
|
||||||
|
-- Older mobile builds still stage picked_required_import.bin, so this is an
|
||||||
|
-- additive completion path rather than a replacement for the legacy one.
|
||||||
|
local function directRequiredImportTarget(self, marker)
|
||||||
|
local fields = {}
|
||||||
|
for line in tostring(marker or ""):gmatch("[^\r\n]+") do
|
||||||
|
fields[#fields + 1] = line
|
||||||
|
if #fields > 4 then break end
|
||||||
|
end
|
||||||
|
local version, path, digest, count = fields[1], fields[2], fields[3], fields[4]
|
||||||
|
if version ~= "v1" or not path or not digest or not count or #digest ~= 32
|
||||||
|
or not digest:match("^%x+$") or not count:match("^%d+$") then
|
||||||
|
return nil, "The completed dependency marker was malformed."
|
||||||
|
end
|
||||||
|
digest = digest:lower()
|
||||||
|
count = tonumber(count)
|
||||||
|
if not count then return nil, "The completed dependency size was invalid." end
|
||||||
|
|
||||||
|
if not self.mods and self._refreshMods then pcall(self._refreshMods, self) end
|
||||||
|
local RequiredImports = require("src.mods.RequiredImports")
|
||||||
|
|
||||||
|
local function matchRow(row, wantedImportId)
|
||||||
|
local manifest = row and row.manifest
|
||||||
|
if not manifest then return nil end
|
||||||
|
for _, spec in ipairs(RequiredImports.specs(manifest)) do
|
||||||
|
if (not wantedImportId or spec.id == wantedImportId)
|
||||||
|
and RequiredImports.path(manifest, spec) == path then
|
||||||
|
return manifest, spec, row.id or manifest.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normal resume: bind the marker to exactly the request that opened picker.
|
||||||
|
if self.pickerPendingModId and self.pickerPendingImportId then
|
||||||
|
for _, row in ipairs(self.mods or {}) do
|
||||||
|
if row.id == self.pickerPendingModId then
|
||||||
|
local manifest, spec, modId = matchRow(row, self.pickerPendingImportId)
|
||||||
|
if manifest then return path, digest, count, manifest, spec, modId end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, "The completed dependency did not match the pending import request."
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Android may recreate GameActivity while DocumentsUI is open. If Lua was
|
||||||
|
-- restarted too, recover by the exact engine-owned destination. The path
|
||||||
|
-- includes the mod id and only a manifest-declared import can match it.
|
||||||
|
for _, row in ipairs(self.mods or {}) do
|
||||||
|
local manifest, spec, modId = matchRow(row)
|
||||||
|
if manifest then return path, digest, count, manifest, spec, modId end
|
||||||
|
end
|
||||||
|
return nil, "The completed dependency did not match an installed mod request."
|
||||||
|
end
|
||||||
|
|
||||||
|
finishDirectRequiredImport = function(self, marker)
|
||||||
|
local path, digestOrErr, nativeSize, manifest, spec, modId =
|
||||||
|
directRequiredImportTarget(self, marker)
|
||||||
|
if not path then
|
||||||
|
self.modNotice = { ok = false, text = tostring(digestOrErr) }
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local digest = digestOrErr
|
||||||
|
local RequiredImports = require("src.mods.RequiredImports")
|
||||||
|
local info = love.filesystem.getInfo(path, "file")
|
||||||
|
if not info or type(info.size) ~= "number" then
|
||||||
|
requiredImportNotice(self, modId, spec.id,
|
||||||
|
"The completed dependency file was not found.")
|
||||||
|
self.modNotice = nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if info.size ~= nativeSize then
|
||||||
|
love.filesystem.remove(path)
|
||||||
|
if RequiredImports.receiptPath then
|
||||||
|
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||||
|
end
|
||||||
|
requiredImportNotice(self, modId, spec.id,
|
||||||
|
("Dependency copy size changed after completion (expected %d bytes, found %d).")
|
||||||
|
:format(nativeSize, info.size))
|
||||||
|
self.modNotice = nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||||
|
if sizeErr then
|
||||||
|
love.filesystem.remove(path)
|
||||||
|
if RequiredImports.receiptPath then
|
||||||
|
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||||
|
end
|
||||||
|
requiredImportNotice(self, modId, spec.id, sizeErr)
|
||||||
|
self.modNotice = nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- No second 665 MiB / 1.46 GiB read: native calculated this digest while
|
||||||
|
-- streaming the selected document into the one final copy.
|
||||||
|
local ok, detail = RequiredImports.acceptStoredDigest(
|
||||||
|
manifest, spec.id, digest, love.filesystem)
|
||||||
|
if not ok then
|
||||||
|
love.filesystem.remove(path)
|
||||||
|
if RequiredImports.receiptPath then
|
||||||
|
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||||
|
end
|
||||||
|
requiredImportNotice(self, modId, spec.id, detail)
|
||||||
|
self.modNotice = nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
self.requiredImportNotice = nil
|
||||||
|
self.modNotice = { ok = true, text = "Imported " .. tostring(spec.id)
|
||||||
|
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||||
|
self:_refreshMods()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function RomImporter:_importRequiredData(modId, importId, data)
|
function RomImporter:_importRequiredData(modId, importId, data)
|
||||||
local manifest = requiredManifest(self, modId)
|
local manifest = requiredManifest(self, modId)
|
||||||
if not manifest then
|
if not manifest then
|
||||||
@@ -2218,7 +2356,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
|||||||
self.pickerPendingModId = modId
|
self.pickerPendingModId = modId
|
||||||
self.pickerPendingImportId = importId
|
self.pickerPendingImportId = importId
|
||||||
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
|
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
|
||||||
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
|
-- Raw imports can go straight to their final private destination on current
|
||||||
|
-- Android/iOS bridges. N64 stays on staging because the launcher still has
|
||||||
|
-- to canonicalize byte order/copier headers before storing it.
|
||||||
|
local directDestination = (self.mobileFileBridge and not legacyAndroidPicker
|
||||||
|
and spec.format ~= "n64"
|
||||||
|
and type(manifest.path) == "string" and manifest.path ~= "")
|
||||||
|
and require("src.mods.RequiredImports").path(manifest, spec) or nil
|
||||||
|
if not pickFile(legacyAndroidPicker and "rom" or "required_import",
|
||||||
|
directDestination) then
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
self.pickerPendingModId = nil
|
self.pickerPendingModId = nil
|
||||||
self.pickerPendingImportId = nil
|
self.pickerPendingImportId = nil
|
||||||
@@ -2549,6 +2695,8 @@ function RomImporter:_pollPickedFiles(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
|
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
|
||||||
|
or love.filesystem.getInfo("pick_error.flag", "file") ~= nil
|
||||||
|
or love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") ~= nil
|
||||||
if not found then
|
if not found then
|
||||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||||
local n = name:lower()
|
local n = name:lower()
|
||||||
|
|||||||
@@ -118,8 +118,13 @@ local systemFile = assert(io.open(systemPath, "rb"))
|
|||||||
local system = systemFile:read("*a")
|
local system = systemFile:read("*a")
|
||||||
systemFile:close()
|
systemFile:close()
|
||||||
check(system:find('strcmp(kind, "required_import")', 1, true)
|
check(system:find('strcmp(kind, "required_import")', 1, true)
|
||||||
and system:find('dest = "picked_required_import.bin"', 1, true)
|
and system:find('destination != nullptr', 1, true)
|
||||||
|
and system:find('"picked_required_import.bin"', 1, true)
|
||||||
and system:find('return "rom,mod,sav,required_import"', 1, true),
|
and system:find('return "rom,mod,sav,required_import"', 1, true),
|
||||||
"native Android bridge advertises and routes required imports")
|
"native Android bridge advertises and routes required imports")
|
||||||
|
check(source:find('normalized.startsWith("mods/")', 1, true)
|
||||||
|
and source:find('/baseroms/', 1, true)
|
||||||
|
and source:find('PICK_COMPLETE_FILENAME', 1, true),
|
||||||
|
"direct required imports stay inside mod baseroms and publish completion")
|
||||||
|
|
||||||
print("android_host_extension_test: ok")
|
print("android_host_extension_test: ok")
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ local saved = {
|
|||||||
pickFileKinds = love.system.pickFileKinds,
|
pickFileKinds = love.system.pickFileKinds,
|
||||||
}
|
}
|
||||||
|
|
||||||
local pickCalls = {}
|
local pickCalls, pickDestinations = {}, {}
|
||||||
love.system.getOS = function() return "Android" end
|
love.system.getOS = function() return "Android" end
|
||||||
love.system.pickFile = function(kind)
|
love.system.pickFile = function(kind, destination)
|
||||||
pickCalls[#pickCalls + 1] = kind or "rom"
|
pickCalls[#pickCalls + 1] = kind or "rom"
|
||||||
|
pickDestinations[#pickCalls] = destination
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
|
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
|
||||||
@@ -103,13 +104,15 @@ ri.nativePicker = true
|
|||||||
ri.mobileFileBridge = true
|
ri.mobileFileBridge = true
|
||||||
ri.mods = { {
|
ri.mods = { {
|
||||||
id = "needs_source",
|
id = "needs_source",
|
||||||
manifest = { id = "needs_source", name = "Needs Source",
|
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source",
|
||||||
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
||||||
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
||||||
} }
|
} }
|
||||||
ri:chooseRequiredImport("needs_source", "source")
|
ri:chooseRequiredImport("needs_source", "source")
|
||||||
eq(pickCalls[1], "required_import",
|
eq(pickCalls[1], "required_import",
|
||||||
"required file asks for the dedicated picker kind")
|
"required file asks for the dedicated picker kind")
|
||||||
|
eq(pickDestinations[1], "mods/needs_source/baseroms/source.bin",
|
||||||
|
"raw required file receives its direct private baseroms destination")
|
||||||
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
|
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
|
||||||
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
|
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
|
||||||
|
|
||||||
@@ -166,6 +169,52 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara
|
|||||||
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
|
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
|
||||||
"focus removes the staged required-file pick")
|
"focus removes the staged required-file pick")
|
||||||
|
|
||||||
|
|
||||||
|
-- Current bridge completion: the large source already lives in final baseroms;
|
||||||
|
-- only the tiny path/digest/size marker crosses the launcher focus path.
|
||||||
|
local directPath = "mods/needs_source/baseroms/source.bin"
|
||||||
|
local directSavedGetInfo = love.filesystem.getInfo
|
||||||
|
love.filesystem.getInfo = function(name, kind)
|
||||||
|
local info = directSavedGetInfo(name, kind)
|
||||||
|
if info and name == directPath then
|
||||||
|
info.size = 12
|
||||||
|
info.modtime = 123456
|
||||||
|
end
|
||||||
|
return info
|
||||||
|
end
|
||||||
|
love.filesystem.createDirectory("mods/needs_source/baseroms")
|
||||||
|
love.filesystem.write(directPath, "source bytes")
|
||||||
|
ri.mods[1].manifest.required_imports[1].md5 = {
|
||||||
|
"fe1eb7483479c3a4e44fd41ce6f6d6ad"
|
||||||
|
}
|
||||||
|
ri.pickerPendingKind = "required_import"
|
||||||
|
ri.pickerPendingModId = "needs_source"
|
||||||
|
ri.pickerPendingImportId = "source"
|
||||||
|
love.filesystem.write("pick_complete.flag",
|
||||||
|
"v1\n" .. directPath .. "\nfe1eb7483479c3a4e44fd41ce6f6d6ad\n12\n")
|
||||||
|
ri:focus(true)
|
||||||
|
check(ri.modNotice ~= nil and ri.modNotice.ok == true,
|
||||||
|
"focus accepts a direct native required-import completion")
|
||||||
|
check(love.filesystem.getInfo(directPath, "file") ~= nil,
|
||||||
|
"direct required import remains in final baseroms")
|
||||||
|
check(love.filesystem.getInfo("pick_complete.flag") == nil,
|
||||||
|
"direct completion marker is consumed")
|
||||||
|
|
||||||
|
-- A native digest that does not match the manifest is rejected and the direct
|
||||||
|
-- copy is removed, so an invalid source cannot masquerade as a validated one.
|
||||||
|
love.filesystem.write(directPath, "source bytes")
|
||||||
|
ri.pickerPendingKind = "required_import"
|
||||||
|
ri.pickerPendingModId = "needs_source"
|
||||||
|
ri.pickerPendingImportId = "source"
|
||||||
|
love.filesystem.write("pick_complete.flag",
|
||||||
|
"v1\n" .. directPath .. "\n00000000000000000000000000000000\n12\n")
|
||||||
|
ri:focus(true)
|
||||||
|
check(ri.requiredImportNotice ~= nil,
|
||||||
|
"bad direct digest reports a dependency validation error")
|
||||||
|
check(love.filesystem.getInfo(directPath, "file") == nil,
|
||||||
|
"bad direct digest removes the rejected final copy")
|
||||||
|
love.filesystem.getInfo = directSavedGetInfo
|
||||||
|
|
||||||
-- Android releases with the updated launcher but the older native bridge do
|
-- Android releases with the updated launcher but the older native bridge do
|
||||||
-- not advertise required_import. They still support the established ROM SAF
|
-- not advertise required_import. They still support the established ROM SAF
|
||||||
-- picker, whose result must be quarantined to the pending dependency request.
|
-- picker, whose result must be quarantined to the pending dependency request.
|
||||||
@@ -176,12 +225,14 @@ ri.nativePicker = true
|
|||||||
ri.mobileFileBridge = true
|
ri.mobileFileBridge = true
|
||||||
ri.mods = { {
|
ri.mods = { {
|
||||||
id = "needs_source",
|
id = "needs_source",
|
||||||
manifest = { id = "needs_source", name = "Needs Source",
|
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source",
|
||||||
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
||||||
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
||||||
} }
|
} }
|
||||||
ri:chooseRequiredImport("needs_source", "source")
|
ri:chooseRequiredImport("needs_source", "source")
|
||||||
eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker")
|
eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker")
|
||||||
|
check(pickDestinations[1] == nil,
|
||||||
|
"legacy ROM fallback receives no nested dependency destination")
|
||||||
check(ri.requiredImportLegacyRomPick,
|
check(ri.requiredImportLegacyRomPick,
|
||||||
"legacy Android ROM picker result is marked as a required import")
|
"legacy Android ROM picker result is marked as a required import")
|
||||||
ri._importRequiredSource = function(self, modId, importId, source)
|
ri._importRequiredSource = function(self, modId, importId, source)
|
||||||
@@ -216,5 +267,8 @@ love.filesystem.remove("picked_mod.zip")
|
|||||||
love.filesystem.remove("picked_save.sav")
|
love.filesystem.remove("picked_save.sav")
|
||||||
love.filesystem.remove("picked_required_import.bin")
|
love.filesystem.remove("picked_required_import.bin")
|
||||||
love.filesystem.remove("picked_rom.gb")
|
love.filesystem.remove("picked_rom.gb")
|
||||||
|
love.filesystem.remove("pick_complete.flag")
|
||||||
|
love.filesystem.remove("pick_error.flag")
|
||||||
|
love.filesystem.remove("mods/needs_source/baseroms/source.bin")
|
||||||
|
|
||||||
S.finish()
|
S.finish()
|
||||||
|
|||||||
Reference in New Issue
Block a user