From ec6b03f84b05e98b5d5049609eeefa14573bb711 Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Tue, 25 Aug 2026 13:22:45 -0400 Subject: [PATCH] fix: stream large required imports on mobile --- .../jni/love/src/modules/system/System.cpp | 5 +- .../src/jni/love/src/modules/system/System.h | 2 +- .../love/src/modules/system/wrap_System.cpp | 3 +- .../java/org/love2d/android/GameActivity.java | 183 +++++++++++++++--- mobile/ios/native/GRPickerBridge.swift | 105 +++++++++- mobile/ios/patch_love_src.py | 15 +- src/import/RomImporter.lua | 152 ++++++++++++++- tests/engine/android_host_extension_test.lua | 7 +- tests/rom_importer_android_mod_pick_test.lua | 62 +++++- 9 files changed, 496 insertions(+), 38 deletions(-) diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 54a5be46..68d1cd5a 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -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 diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 2fcfad46..a15c3bea 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -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; /** diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 0acdbf0c..0f65b740 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -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; } diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 2fe59948..8490fa50 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -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()); } diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index 06f0db26..26de1c85 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -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?, saveDir: UnsafePointer?) -> Bool { + return presentPicker(kind: kind, saveDir: saveDir, destination: nil) + } + + @objc(presentPickerWithKind:saveDir:destination:) + public static func presentPicker(kind: UnsafePointer?, + saveDir: UnsafePointer?, + destination: UnsafePointer?) -> 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")) } } diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index 837210d8..9f268443 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index c385786b..364de276 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea") local 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 -- 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 @@ -1582,8 +1585,11 @@ function RomImporter:focus(f) .. love.filesystem.getSaveDirectory() local legacyRequiredPick = self.requiredImportLegacyRomPick 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("/baseroms/", 1, true) + or pickError:find("\\baseroms\\", 1, true) or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then self.modNotice = { ok = false, text = text } self.pickerPendingKind = nil @@ -1606,6 +1612,22 @@ function RomImporter:focus(f) end return end + + -- Current mobile bridge: the native picker has already streamed a raw + -- dependency into mods//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) if requiredName then local modId, importId = self.pickerPendingModId, self.pickerPendingImportId @@ -2059,6 +2081,122 @@ local function requiredImportNotice(self, modId, importId, text) } 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\n\n\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) local manifest = requiredManifest(self, modId) if not manifest then @@ -2218,7 +2356,15 @@ function RomImporter:chooseRequiredImport(modId, importId) self.pickerPendingModId = modId self.pickerPendingImportId = importId 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.pickerPendingModId = nil self.pickerPendingImportId = nil @@ -2549,6 +2695,8 @@ function RomImporter:_pollPickedFiles(dt) return end 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 for _, name in ipairs(love.filesystem.getDirectoryItems("")) do local n = name:lower() diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index efe7a839..2f6815e4 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -118,8 +118,13 @@ local systemFile = assert(io.open(systemPath, "rb")) local system = systemFile:read("*a") systemFile:close() 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), "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") diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua index bc88882f..8789067e 100644 --- a/tests/rom_importer_android_mod_pick_test.lua +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -17,10 +17,11 @@ local saved = { pickFileKinds = love.system.pickFileKinds, } -local pickCalls = {} +local pickCalls, pickDestinations = {}, {} love.system.getOS = function() return "Android" end -love.system.pickFile = function(kind) +love.system.pickFile = function(kind, destination) pickCalls[#pickCalls + 1] = kind or "rom" + pickDestinations[#pickCalls] = destination return true end love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end @@ -103,13 +104,15 @@ ri.nativePicker = true ri.mobileFileBridge = true ri.mods = { { 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", format = "raw", md5 = { "00000000000000000000000000000000" } } } }, } } ri:chooseRequiredImport("needs_source", "source") eq(pickCalls[1], "required_import", "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.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, "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 -- not advertise required_import. They still support the established ROM SAF -- picker, whose result must be quarantined to the pending dependency request. @@ -176,12 +225,14 @@ ri.nativePicker = true ri.mobileFileBridge = true ri.mods = { { 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", format = "raw", md5 = { "00000000000000000000000000000000" } } } }, } } ri:chooseRequiredImport("needs_source", "source") 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, "legacy Android ROM picker result is marked as a required import") 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_required_import.bin") 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()