mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
902f0d73d4
Makes the iOS build a first-class citizen: ROM/mod/save import through
the system document picker (the README's missing "UIDocumentPicker
handoff"), Files-app drop-in support, and an opt-in Apple Health
step-sync seam consumed by a new gallery mod (Pokewalker).
Native layer (mobile/ios/native/, wired by mobile/ios/patch_love_src.py
on every build, so the fetched love-src tree stays pristine + re-patchable):
- GRPickerBridge.swift: love.system.pickFile("rom"|"mod"|"sav") and
love.system.createFile on iOS with the same contract as love-android's
SAF picker (picked_rom.gb / picked_mod.zip / picked_save.sav /
export_done.flag in the save dir). Reached from wrap_System.cpp via the
ObjC runtime, so liblove needs no Swift interop.
- GRBootstrap.m: sweeps .gb/.gbc/.zip/.sav dropped in Documents (Files
app / Finder sharing) into the save dir on every activation;
UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace in the plist
overlay. Drop a ROM, open the app, it imports with zero taps.
- GRHealthBridge.swift: love.system.syncHealthSteps() -> read-only
HealthKit step query anchored to the last sync, delivered as
steps_pending.json (merge-not-overwrite). HealthKit entitlement +
usage description included.
Lua:
- RomImporter: iOS rides the Android mobile flows; a 0.5s poll consumes
picker deliveries (iOS pickers are in-process modals, so the Android
refocus rescan never fires); failed pick copies surface as an
on-screen notice via pick_error.txt.
- main.lua: on iOS, stop forwarding touchpressed to the Importer - LOVE
already synthesizes a mousepressed for the primary touch, and the
resulting same-frame double-present made the document picker
auto-dismiss with zero documents (silent import failure).
- mods/pokewalker: opt-in Pokewalker mod (manifest v2, MECHANIC,
permissions declared, mod.card, CHANGELOG, headless test suite 9/9,
modkit validate --base imported + lint clean). Fused into iOS
game.love only; loads dormant anywhere without the bridge.
Build (scripts/build_ios.sh):
- Fix Xcode 26: the global PRODUCT_NAME override also renamed liblove.a
and broke the app link; the app bundle is renamed after the build
instead.
- Fix nondeterministic pack failures: grep -q + pipefail races SIGPIPE
on the game.love content checks.
- Simulator builds sign ad-hoc so entitlements embed (HealthKit works in
the simulator).
- Device builds: signing team auto-detected from the keychain,
CODE_SIGN_STYLE=Automatic + -allowProvisioningUpdates for CLI-only
provisioning, per-team derived bundle ID (explicit App IDs are
globally unique, so third parties can't sign the project default),
gitignored mobile/ios/bundle_id.local pin, and --install to push to a
connected iPhone.
- docs/ios-install.md: a zero-knowledge walkthrough from bare Mac to
playing on an iPhone.
Backward compatibility: no behavior change on desktop or Android. The
new love.system functions exist only under LOVE_IOS; RomImporter's
mobile flag simply includes iOS alongside Android; the main.lua change
is iOS-gated; the Pokewalker mod is packed only by the iOS build script
and its option defaults off.
Verified on an iPhone 17 Pro simulator and an iPhone 16 Pro device:
scripted ROM import to title screen, Files-drop zero-tap import,
picker-driven mod install and save import/export, HealthKit permission
sheet + step credit (4000 steps -> +200 EXP at the default rate through
the engine growth curve).
219 lines
9.8 KiB
Swift
219 lines
9.8 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] = []
|
|
|
|
// conf.lua t.identity — where LÖVE puts the fused save directory on iOS
|
|
// (<sandbox>/Library/Application Support/<identity>).
|
|
private static let loveIdentity = "pokemon-love2d"
|
|
|
|
// 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"
|
|
default:
|
|
destName = "picked_rom.gb"
|
|
for ext in ["gb", "gbc"] {
|
|
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
|
}
|
|
}
|
|
// .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)
|
|
}
|
|
|
|
@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
|
|
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
|
|
let appSupport = fm.urls(for: .applicationSupportDirectory,
|
|
in: .userDomainMask).first else { return }
|
|
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
|
|
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)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
|
var dir = cstr.map { String(cString: $0) } ?? ""
|
|
if dir.isEmpty {
|
|
guard let appSupport = FileManager.default
|
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
else { return nil }
|
|
dir = appSupport.appendingPathComponent(loveIdentity).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 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 liveDelegates.isEmpty else {
|
|
NSLog("GRPickerBridge: picker already active; ignoring re-present")
|
|
return true
|
|
}
|
|
guard var top = UIApplication.shared.windows
|
|
.first(where: { $0.isKeyWindow })?.rootViewController
|
|
else { return false }
|
|
while let presented = top.presentedViewController { top = presented }
|
|
picker.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 {
|
|
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?()
|
|
}
|
|
}
|