// gen1recomp iOS native bridge: document picker + Files-app inbox sweep. // // liblove's wrap_System.cpp calls into this class through the Objective-C // runtime (objc_getClass / objc_msgSend), so nothing here may be renamed // without updating that patch (see mobile/ios/patch_love_src.py). // // Contract (mirrors love-android's GameActivity.showFilePicker): // love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into // the LÖVE save directory as picked_rom.gb / picked_mod.zip / // picked_save.sav; RomImporter's pending-file scan consumes it. // love.system.createFile(name) -> exports save dir's pending_export.sav // through the system picker, then writes export_done.flag. import UIKit import UniformTypeIdentifiers @objc(GRPickerBridge) public final class GRPickerBridge: NSObject { // Every live picker keeps its own delegate here // (UIDocumentPickerViewController holds its delegate weakly). A single // "current delegate" slot would break under double activation: the // engine's touch handling can fire a button twice (touch + synthesized // mouse), the second present would replace the first picker's delegate, // and the sheet the user actually sees would then pick into nil — // silently doing nothing. private static var liveDelegates: [PickerDelegate] = [] private static let loveIdentity = "pokemon-love2d" @objc(httpDownloadWithUrl:destination:userAgent:accept:) public static func httpDownload(url: UnsafePointer?, destination: UnsafePointer?, userAgent: UnsafePointer?, accept: UnsafePointer?) -> Bool { guard let url, let destination, let requestURL = URL(string: String(cString: url)) else { return false } var request = URLRequest(url: requestURL) request.timeoutInterval = 300 if let userAgent, userAgent.pointee != 0 { request.setValue(String(cString: userAgent), forHTTPHeaderField: "User-Agent") } if let accept, accept.pointee != 0 { request.setValue(String(cString: accept), forHTTPHeaderField: "Accept") } let target = URL(fileURLWithPath: String(cString: destination)) let semaphore = DispatchSemaphore(value: 0) var succeeded = false let task = URLSession.shared.downloadTask(with: request) { temporary, response, error in defer { semaphore.signal() } guard error == nil, let temporary, let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return } try? FileManager.default.removeItem(at: target) do { try FileManager.default.moveItem(at: temporary, to: target) succeeded = true } catch { succeeded = false } } task.resume() guard semaphore.wait(timeout: .now() + 305) == .success else { task.cancel() return false } return succeeded } // MARK: - Entry points called from liblove (C strings on purpose) @objc(presentPickerWithKind:saveDir:) public static func presentPicker(kind: UnsafePointer?, saveDir: UnsafePointer?) -> Bool { let kindStr = kind.map { String(cString: $0) } ?? "rom" guard let dir = resolvedSaveDir(saveDir) else { return false } let destName: String var types: [UTType] = [] switch kindStr { case "mod": destName = "picked_mod.zip" types = [.zip] case "sav": destName = "picked_save.sav" // 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 // picked_rom.gb is swept up by the Game Boy importer, deleted, and // reported to the player as a broken cartridge. case "stadium": destName = "picked_stadium.z64" for ext in ["z64", "n64", "v64"] { if let t = UTType(filenameExtension: ext) { types.append(t) } } case "rom", "": destName = "picked_rom.gb" for ext in ["gb", "gbc"] { if let t = UTType(filenameExtension: ext) { types.append(t) } } // An unknown kind is REFUSED rather than treated as a Game Boy ROM. // // It used to fall through to picked_rom.gb, so a caller asking for a // kind this build had never heard of got its file deleted and // reported as a broken cartridge -- the worst possible answer to // "I do not know that one". Returning false lets the caller find out // and offer its own fallback. default: return false } // .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering // .data as well keeps every real file selectable. The importer // validates by size + SHA-1, so a wrong pick is rejected safely. types.append(.data) if !types.contains(.item) { types.append(.item) } let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) picker.allowsMultipleSelection = false let delegate = PickerDelegate { urls in guard let src = urls.first else { return } copyItem(at: src, into: dir, named: destName) } return present(picker, with: delegate) } // Which kinds presentPicker understands, comma separated. // // So a CALLER can ask before it calls. A mod that wants a kind this build // predates cannot otherwise tell "refused" from "the picker would not // open", and guessing wrong used to cost the player their ROM (see the // default case above). Asking first turns that into a fallback the caller // chooses rather than a file it loses. // // Kept beside the switch it describes, because the two drifting apart is // the only way this can lie. @objc public static func supportedPickerKinds() -> NSString { return "rom,mod,sav,stadium" as NSString } @objc(presentExportWithName:saveDir:) public static func presentExport(name: UnsafePointer?, saveDir: UnsafePointer?) -> Bool { let suggested = name.map { String(cString: $0) } ?? "export.sav" guard let dir = resolvedSaveDir(saveDir) else { return false } let staged = dir.appendingPathComponent("pending_export.sav") guard FileManager.default.fileExists(atPath: staged.path) else { return false } // Stage under the suggested name so the picker's filename field is // prefilled; forExporting moves/copies it to the user's destination. let tmp = FileManager.default.temporaryDirectory .appendingPathComponent(suggested) try? FileManager.default.removeItem(at: tmp) do { try FileManager.default.copyItem(at: staged, to: tmp) } catch { NSLog("GRPickerBridge: staging export failed: \(error)") return false } let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true) let delegate = PickerDelegate { urls in guard !urls.isEmpty else { return } // Same completion signal love-android's GameActivity writes; // RomImporter:focus consumes it and clears pending_export.sav. let flag = dir.appendingPathComponent("export_done.flag") try? "ok".data(using: .utf8)?.write(to: flag) } return present(picker, with: delegate) } // Moves ROM/mod/save files the user dropped into the app's Documents // folder (Files app / Finder file sharing) into the LÖVE save directory, // where the importer's pending-file scan looks. Called on every // UIApplicationDidBecomeActive (see GRBootstrap.m). @objc public static func sweepInbox() { let fm = FileManager.default migrateLegacySaveDirectory() guard let docs = documentsDirectory(), let saveDir = publicSaveDirectory() else { return } guard docs.standardizedFileURL != saveDir.standardizedFileURL else { return } let wanted: Set = ["gb", "gbc", "zip", "sav"] guard let items = try? fm.contentsOfDirectory(at: docs, includingPropertiesForKeys: nil) else { return } for url in items where wanted.contains(url.pathExtension.lowercased()) { ensureDirectory(saveDir) let dest = saveDir.appendingPathComponent(url.lastPathComponent) try? fm.removeItem(at: dest) do { try fm.moveItem(at: url, to: dest) NSLog("GRPickerBridge: swept %@ into save dir", url.lastPathComponent) } catch { NSLog("GRPickerBridge: sweep failed for \(url.lastPathComponent): \(error)") } } } @objc public static func preparePublicDocuments() { migrateLegacySaveDirectory() if let saveDir = publicSaveDirectory() { ensureDirectory(saveDir) } } // MARK: - Helpers private static func resolvedSaveDir(_ cstr: UnsafePointer?) -> URL? { var dir = cstr.map { String(cString: $0) } ?? "" if dir.isEmpty { migrateLegacySaveDirectory() guard let saveDir = publicSaveDirectory() else { return nil } dir = saveDir.path } let url = URL(fileURLWithPath: dir, isDirectory: true) ensureDirectory(url) return url } private static func ensureDirectory(_ url: URL) { try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } private static func documentsDirectory() -> URL? { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first } private static func publicSaveDirectory() -> URL? { documentsDirectory() } private static func legacySaveDirectory() -> URL? { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? .appendingPathComponent(loveIdentity, isDirectory: true) } private static func migrateLegacySaveDirectory() { let fm = FileManager.default guard let destination = publicSaveDirectory(), let legacy = legacySaveDirectory(), fm.fileExists(atPath: legacy.path) else { return } ensureDirectory(destination) mergeDirectory(from: legacy, to: destination) try? fm.removeItem(at: legacy) } private static func mergeDirectory(from source: URL, to destination: URL) { let fm = FileManager.default ensureDirectory(destination) guard let items = try? fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) else { return } for item in items { let target = destination.appendingPathComponent(item.lastPathComponent) var sourceIsDirectory = ObjCBool(false) fm.fileExists(atPath: item.path, isDirectory: &sourceIsDirectory) var targetIsDirectory = ObjCBool(false) let targetExists = fm.fileExists(atPath: target.path, isDirectory: &targetIsDirectory) if sourceIsDirectory.boolValue && targetExists && targetIsDirectory.boolValue { mergeDirectory(from: item, to: target) continue } if targetExists { if !sourceIsDirectory.boolValue && !targetIsDirectory.boolValue && fm.contentsEqual(atPath: item.path, andPath: target.path) { try? fm.removeItem(at: item) } else { moveToLegacyName(item, in: destination) } continue } try? fm.moveItem(at: item, to: target) } } private static func moveToLegacyName(_ item: URL, in destination: URL) { let fm = FileManager.default let base = item.lastPathComponent + ".legacy" var target = destination.appendingPathComponent(base) var suffix = 2 while fm.fileExists(atPath: target.path) { target = destination.appendingPathComponent("\(base).\(suffix)") suffix += 1 } try? fm.moveItem(at: item, to: target) } private static func copyItem(at src: URL, into dir: URL, named name: String) { let scoped = src.startAccessingSecurityScopedResource() defer { if scoped { src.stopAccessingSecurityScopedResource() } } ensureDirectory(dir) let dest = dir.appendingPathComponent(name) try? FileManager.default.removeItem(at: dest) do { try FileManager.default.copyItem(at: src, to: dest) NSLog("GRPickerBridge: delivered %@", name) } catch { NSLog("GRPickerBridge: copy failed: \(error)") // Surface the failure in-game: the Lua pick poll turns this // into an on-screen notice instead of a silent no-op. let report = "Could not copy \(src.lastPathComponent): " + error.localizedDescription try? report.data(using: .utf8)? .write(to: dir.appendingPathComponent("pick_error.txt")) } } private static func present(_ picker: UIDocumentPickerViewController, with delegate: PickerDelegate) -> Bool { let doPresent = { () -> Bool in // Double activation (touch + synthesized mouse) arrives within // one frame — before UIKit even exposes the first sheet via // presentedViewController — so gate on our own delegate list. // Without this the stacked present makes the picker auto-dismiss // with zero documents (observed as didPickDocumentsAt 0 urls) // and the user's pick silently does nothing. guard var top = UIApplication.shared.windows .first(where: { $0.isKeyWindow })?.rootViewController else { return false } // Self-heal before consulting the list. If UIKit is presenting // nothing at all, any delegate still in it belongs to a sheet // that is long gone, and treating it as live would lock the // picker out for the rest of the session. Belt and braces with // the dismissal callback above: that one closes the known hole, // this one closes whatever hole iOS invents next. if top.presentedViewController == nil, !liveDelegates.isEmpty { NSLog("GRPickerBridge: clearing %d stale delegate(s)", liveDelegates.count) liveDelegates.removeAll() } guard liveDelegates.isEmpty else { NSLog("GRPickerBridge: picker already active; ignoring re-present") return true } while let presented = top.presentedViewController { top = presented } picker.delegate = delegate picker.presentationController?.delegate = delegate liveDelegates.append(delegate) delegate.onFinish = { [weak delegate] in liveDelegates.removeAll { $0 === delegate } } top.present(picker, animated: true) return true } if Thread.isMainThread { return doPresent() } var ok = false DispatchQueue.main.sync { ok = doPresent() } return ok } } private final class PickerDelegate: NSObject, UIDocumentPickerDelegate, UIAdaptivePresentationControllerDelegate { private let onPick: ([URL]) -> Void var onFinish: (() -> Void)? init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { NSLog("GRPickerBridge: didPickDocumentsAt %d url(s)", urls.count) onPick(urls) onFinish?() } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { // No file was written; the Lua side's pending-file poll simply // never finds anything. NSLog("GRPickerBridge: picker cancelled") onFinish?() } // Swiping the sheet down calls NEITHER of the two above: since iOS 13 an // interactively dismissed picker reports only through the adaptive // presentation delegate. Without this the delegate is never taken out of // liveDelegates, the re-present guard below then swallows every later // picker while still answering true -- so Lua arms its poll and waits for // a file that no sheet is ever going to produce. That is the whole of the // "Import ROM does nothing until you restart the app" report: the restart // is not refreshing anything, it is clearing this array. func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { NSLog("GRPickerBridge: picker dismissed interactively") onFinish?() } }