mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
391 lines
17 KiB
Swift
391 lines
17 KiB
Swift
// 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<CChar>?,
|
|
destination: UnsafePointer<CChar>?,
|
|
userAgent: UnsafePointer<CChar>?,
|
|
accept: UnsafePointer<CChar>?) -> 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<CChar>?,
|
|
saveDir: UnsafePointer<CChar>?) -> 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<CChar>?,
|
|
saveDir: UnsafePointer<CChar>?) -> 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<String> = ["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<CChar>?) -> 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?()
|
|
}
|
|
}
|