iOS: native document-picker + Apple Health bridges, working device builds

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).
This commit is contained in:
Myles Resnick
2026-07-30 09:46:48 -04:00
parent 29afbdaec7
commit 902f0d73d4
18 changed files with 1315 additions and 15 deletions
+67 -1
View File
@@ -482,7 +482,12 @@ end
-- supplied the Edit label is not drawn at all).
function RomImporter.new(onComplete, opts)
opts = opts or {}
local android = love.system.getOS() == "Android"
-- iOS rides the same mobile import flows as Android: the save-dir
-- pending-file scan plus love.system.pickFile / createFile, provided
-- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag
-- keeps its historical name so every Android call site stays untouched.
local mobileOS = love.system.getOS()
local android = mobileOS == "Android" or mobileOS == "iOS"
local CacheFs = require("src.import.CacheFs")
local self = setmetatable({
onComplete = onComplete,
@@ -490,6 +495,11 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
android = android,
ios = mobileOS == "iOS",
-- One startup poll pass: files dropped through the Files app are swept
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
-- event necessarily follows, so consume them via the first poll tick.
pickPending = mobileOS == "iOS" or nil,
-- Android drag: the launcher is handed no move events at all (main.lua
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
-- emulation is what "no reliable pointer polling" below refers to.
@@ -617,6 +627,11 @@ end
-- up without the player needing to tap the button again. Mod and save SAF
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
-- ROM pick cannot steal the focus path when both games are already ready.
-- NOTE (iOS): do NOT clear pickPending here. The picker's dismissal focus
-- event can arrive before the Swift delegate has finished copying the pick
-- into the save dir; if this scan runs early and finds nothing, the poll in
-- _pollPickedFiles must stay armed so it consumes the file when it lands
-- moments later (it clears pickPending itself once something is found).
function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag.
@@ -860,6 +875,9 @@ function RomImporter:chooseMod()
if not love.system.pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
else
self.pickPending = true
self.pickTimer = 0
end
return
end
@@ -922,6 +940,9 @@ function RomImporter:chooseSaveImport(version)
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
else
self.pickPending = true
self.pickTimer = 0
end
return
end
@@ -958,6 +979,8 @@ function RomImporter:exportSave(version)
end
self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then
self.pickPending = true
self.pickTimer = 0
self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." }
else
@@ -1008,6 +1031,9 @@ function RomImporter:choose(version)
status = "No picker available, copy your ROM into:",
detail = love.filesystem.getSaveDirectory(),
}
else
self.pickPending = true
self.pickTimer = 0
end
return
end
@@ -1042,9 +1068,49 @@ function RomImporter:choose(version)
end
end
-- iOS: the document picker is an in-process modal sheet, so unlike Android's
-- separate SAF activity there is no love.focus(true) when it dismisses.
-- While a pick is outstanding, poll the save dir for the bridge's delivered
-- file (picked_rom.gb / picked_mod.zip / picked_save.sav / export_done.flag)
-- and run the same refocus import path Android uses.
function RomImporter:_pollPickedFiles(dt)
if not (self.ios and self.pickPending) then return end
if self.workState == "working" then return end
self.pickTimer = (self.pickTimer or 0) + dt
if self.pickTimer < 0.5 then return end
self.pickTimer = 0
-- The Swift bridge reports a failed pick copy through pick_error.txt;
-- surface it on whichever tab the player is looking at rather than
-- letting the pick silently do nothing.
local pickError = love.filesystem.read("pick_error.txt")
if pickError then
love.filesystem.remove("pick_error.txt")
self.pickPending = nil
self.modNotice = { ok = false, text = pickError }
self.notice = { version = self.chooseVersion or "red",
status = "File import failed:", detail = pickError }
return
end
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower()
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
found = true
break
end
end
end
if found then
self.pickPending = nil
self:focus(true)
end
end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
self:_updatePadCursor(dt)
self:_pollPickedFiles(dt)
if self.workState ~= "working" or not self.worker then return end
local started = love.timer.getTime()
repeat