mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +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).
122 lines
5.1 KiB
Swift
122 lines
5.1 KiB
Swift
// gen1recomp iOS native bridge: Apple Health step sync (Pokéwalker mode).
|
|
//
|
|
// Reached from liblove's wrap_System.cpp through the Objective-C runtime as
|
|
// love.system.syncHealthSteps() — see mobile/ios/patch_love_src.py. The Lua
|
|
// side (mods/pokewalker) opts in, calls sync, and later consumes
|
|
// steps_pending.json from the LÖVE save directory:
|
|
//
|
|
// { "steps": 4312, "from": "<ISO8601>", "to": "<ISO8601>" }
|
|
//
|
|
// Steps are counted from a persisted anchor (last successful sync; first
|
|
// run starts at midnight today) so the same walk is never credited twice.
|
|
// An unconsumed pending file is merged, not overwritten, so steps survive
|
|
// the player quitting before entering the overworld.
|
|
|
|
import Foundation
|
|
import HealthKit
|
|
|
|
@objc(GRHealthBridge)
|
|
public final class GRHealthBridge: NSObject {
|
|
|
|
private static let store = HKHealthStore()
|
|
private static let anchorKey = "GRHealthLastSyncDate"
|
|
private static let pendingName = "steps_pending.json"
|
|
// Sanity clamp per sync: guards against absurd backlogs (device clock
|
|
// changes, months-old anchors) turning into instant level-100 parties.
|
|
private static let maxStepsPerSync = 50_000
|
|
|
|
/// love.system.syncHealthSteps() -> bool ("sync started").
|
|
/// Requests read authorization on first use (system sheet appears over
|
|
/// the game), then asynchronously writes the pending-steps file.
|
|
@objc(syncStepsWithCommand:saveDir:)
|
|
public static func syncSteps(command: UnsafePointer<CChar>?,
|
|
saveDir: UnsafePointer<CChar>?) -> Bool {
|
|
guard HKHealthStore.isHealthDataAvailable(),
|
|
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)
|
|
else { return false }
|
|
let dir = saveDir.map { String(cString: $0) } ?? ""
|
|
guard !dir.isEmpty else { return false }
|
|
|
|
store.requestAuthorization(toShare: nil, read: [stepType]) { _, error in
|
|
if let error = error {
|
|
NSLog("GRHealthBridge: authorization error: \(error)")
|
|
return
|
|
}
|
|
// Read authorization is intentionally opaque (a denied query
|
|
// just returns no samples), so always run the query.
|
|
runQuery(stepType: stepType, dir: dir)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Query
|
|
|
|
private static func runQuery(stepType: HKQuantityType, dir: String) {
|
|
let defaults = UserDefaults.standard
|
|
let now = Date()
|
|
let anchor = (defaults.object(forKey: anchorKey) as? Date)
|
|
?? Calendar.current.startOfDay(for: now)
|
|
guard anchor < now else { return }
|
|
|
|
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: now,
|
|
options: .strictStartDate)
|
|
let query = HKStatisticsQuery(quantityType: stepType,
|
|
quantitySamplePredicate: predicate,
|
|
options: .cumulativeSum) { _, stats, error in
|
|
if let error = error {
|
|
// Typical on first run before the user answers the sheet,
|
|
// or when access is denied; the anchor is left untouched so
|
|
// the next sync retries the same window.
|
|
NSLog("GRHealthBridge: step query error: \(error)")
|
|
return
|
|
}
|
|
let steps = Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
|
|
defaults.set(now, forKey: anchorKey)
|
|
deliver(steps: min(steps, maxStepsPerSync),
|
|
from: anchor, to: now, dir: dir)
|
|
}
|
|
store.execute(query)
|
|
}
|
|
|
|
// MARK: - Pending file
|
|
|
|
private static func deliver(steps: Int, from: Date, to: Date, dir: String) {
|
|
let url = URL(fileURLWithPath: dir).appendingPathComponent(pendingName)
|
|
|
|
// Merge with an unconsumed earlier delivery so steps are never lost.
|
|
var total = steps
|
|
var fromDate = from
|
|
if let data = try? Data(contentsOf: url),
|
|
let old = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
|
total += (old["steps"] as? Int) ?? 0
|
|
if let oldFrom = old["from"] as? String,
|
|
let parsed = isoFormatter.date(from: oldFrom), parsed < fromDate {
|
|
fromDate = parsed
|
|
}
|
|
}
|
|
guard total > 0 else { return }
|
|
|
|
let payload: [String: Any] = [
|
|
"steps": total,
|
|
"from": isoFormatter.string(from: fromDate),
|
|
"to": isoFormatter.string(from: to),
|
|
]
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: URL(fileURLWithPath: dir, isDirectory: true),
|
|
withIntermediateDirectories: true)
|
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
|
try data.write(to: url, options: .atomic)
|
|
NSLog("GRHealthBridge: %d steps pending", total)
|
|
} catch {
|
|
NSLog("GRHealthBridge: could not write pending steps: \(error)")
|
|
}
|
|
}
|
|
|
|
private static let isoFormatter: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
}
|