Merge pull request #1058 from castdrian/ios-documents

feat(ios): expose app data in Documents dir
This commit is contained in:
bryanthaboi
2026-08-10 17:07:05 -04:00
committed by GitHub
5 changed files with 269 additions and 117 deletions
+87 -104
View File
@@ -1,130 +1,113 @@
# iOS build (LÖVE 12.0)
# iOS build
> **Native ROM/mod/save import.** The iOS build ships a Swift
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
> that `patch_love_src.py` wires into the LÖVE tree on every build:
>
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
> `picked_save.sav`; exports signal via `export_done.flag`).
> - The Info.plist overlay enables `UIFileSharingEnabled` +
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
> into the LÖVE save dir on every activation — drop a ROM, open the app,
> and it imports with no taps.
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
> for picker results (iOS pickers are in-process modals, so Android's
> refocus rescan never fires).
>
> The note below about a missing "UIDocumentPicker handoff" is
> resolved by this bridge.
This directory contains the macOS/Xcode build used to package Gen1 Recomp as
an iOS app with LÖVE 12.0.
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
dependencies from the official [LÖVE source](https://github.com/love2d/love)
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
## User data location
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
The app uses the public iOS Documents directory as its LÖVE save directory.
There is no `pokemon-love2d` subdirectory and the app does not create a
README file there. When browsing `On My iPhone > gen1recomp` in Files, the
directory contains the app's runtime data directly, including:
## Quick start (simulator)
- installed mods and downloaded ROMs
- save files and save-state data
- options, caches, logs, and other files created by the game
The build enables `UIFileSharingEnabled` and
`LSSupportsOpeningDocumentsInPlace`, so the same directory is available in
Files and Finder. Files copied into the app's Documents directory are used by
the game on its next activation.
Existing installations are migrated automatically. Files from the old
private `Application Support/pokemon-love2d` directory are merged into
Documents on launch; conflicts are retained with a `.legacy` suffix.
## Build
Run these commands from the repository root:
```bash
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
scripts/build_ios.sh --fetch
scripts/build_ios.sh
```
The embedded `game.love` contains no ROM or generated game data. The current
first-boot importer has desktop file pickers only, so a production iOS release
still needs a UIDocumentPicker handoff that passes the selected ROM to LÖVE.
`--fetch` downloads the pinned LÖVE source and matching Apple dependencies
into the gitignored `love-src/` directory. It is only needed when that tree is
missing. The default build targets the iOS Simulator in Debug configuration.
Default output: an unsigned Simulator `.app` under `mobile/ios/build/`
(no Apple Developer account required). A convenience copy also lands under
`dist/ios/<Config>-<sdk>/`.
Install on a booted simulator (example):
For a physical device or a release build:
```bash
xcrun simctl install booted mobile/ios/build/Build/Products/Debug-iphonesimulator/PokemonRed.app
xcrun simctl launch booted com.theboisclub.pokemonred
scripts/build_ios.sh --device --install
scripts/build_ios.sh --device --release --install
```
Or open `mobile/ios/love-src/platform/xcode/love.xcodeproj` in Xcode,
select the `love-ios` target, and Run on a Simulator after
`scripts/build_ios.sh --package-only` (or a full build) has placed `game.love`.
Device builds require a paired, unlocked device and a valid Apple signing
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
signing cannot select the intended account. Add `--ipa` to create
`dist/ios/gen1recomp.ipa`.
## Device / Release
The script verifies the final app before packaging it:
```bash
scripts/build_ios.sh --device # Debug, physical device SDK
scripts/build_ios.sh --device --release # Release configuration
- the public Documents plist settings are present
- the native picker bridge is present
- `game.love` exists and is non-empty
If the payload is missing, the build fails instead of producing a blank app.
## Useful options
| Option | Purpose |
| --- | --- |
| `--fetch` | Fetch LÖVE 12.0 and Apple dependencies when `love-src/` is missing |
| `--device` | Build for `iphoneos` instead of the Simulator |
| `--release` | Use the Release configuration |
| `--install` | Install a device build on the first connected device |
| `--ipa` | Create an IPA after a device build |
| `--version X.Y.Z` | Stamp the engine and app version |
| `--package-only` | Package `game.love` and apply the iOS plist overlay without Xcode |
`scripts/build.sh ios` delegates to this script and forwards the iOS release
option.
## Output
Simulator and device app bundles are copied to:
```text
dist/ios/Debug-iphonesimulator/gen1recomp.app
dist/ios/Release-iphonesimulator/gen1recomp.app
dist/ios/Debug-iphoneos/gen1recomp.app
dist/ios/Release-iphoneos/gen1recomp.app
```
Device builds need a signing identity and provisioning profile configured in
Xcode (or via `DEVELOPMENT_TEAM` / `CODE_SIGN_IDENTITY` env vars). This repo
does **not** store certificates, profiles, or App Store Connect secrets.
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
are gitignored.
Manual out-of-band steps:
1. Apple Developer account + App ID for `com.theboisclub.pokemonred`
2. Development or Distribution certificate + provisioning profile
3. In Xcode: open `love.xcodeproj` → target `love-ios` → Signing & Capabilities
→ select your Team (or set `DEVELOPMENT_TEAM=XXXXXXXXXX` when invoking
`scripts/build_ios.sh --device`)
4. Archive / export an `.ipa` from Xcode Organizer for TestFlight / Ad Hoc
## Layout
| Path | Role |
|------|------|
| `LOVE_VERSION` | Engine pin (`12.0`) |
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
| `build/` | `xcodebuild` derived data (**gitignored**) |
Game payload lands at:
`love-src/platform/xcode/ios/resources/game.love`
and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
## Apple libraries dependency
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
SDL3 framework from
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
Re-run it if either dependency directory is absent.
The bundled game payload is staged at
`love-src/platform/xcode/ios/resources/game.love` and copied into the final
app bundle. The payload contains the game, not user-generated ROMs, mods, or
saves; those are created at runtime in Documents.
## App identity
| Field | Value |
|-------|--------|
| Display name | Pokemon Red |
| `PRODUCT_NAME` | PokemonRed |
| Bundle ID | `com.theboisclub.pokemonred` |
| Orientations | Portrait only (`UIInterfaceOrientationPortrait`) |
| Field | Default |
| --- | --- |
| Display name | `gen1recomp` |
| Product name | `gen1recomp` |
| Bundle identifier | `com.theboisclub.gen1recomp` |
| Save directory | Public `Documents` root |
| Orientation | Portrait |
Overrides are applied by the build script (`xcodebuild` settings + plist overlay)
so refreshing `love-src/` does not lose branding.
Set `GEN1_BUNDLE_ID` to use a different bundle identifier for local device
builds.
## Flags (`scripts/build_ios.sh`)
## Prerequisites
| Flag | Meaning |
|------|---------|
| *(default)* | Simulator, Debug, no signing |
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
| `--release` | `Release` configuration instead of `Debug` |
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
- macOS with Xcode and `xcodebuild`
- the iOS and iOS Simulator platforms installed in Xcode
- a fetched `love-src/` tree, or the `--fetch` option
- the matching iOS libraries and SDL3 framework under `love-src/`
Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
## Preconditions
- macOS (Darwin) with Xcode + `xcodebuild` on `PATH`
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
- `love-src/` present (`--fetch`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
Use `xcodebuild -showsdks` to confirm that the required SDKs are installed.
+3
View File
@@ -16,6 +16,9 @@ static void GRBootstrapInstall(void)
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
Class bridge = NSClassFromString(@"GRPickerBridge");
if ([bridge respondsToSelector:@selector(preparePublicDocuments)]) {
[bridge performSelector:@selector(preparePublicDocuments)];
}
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
[bridge performSelector:@selector(sweepInbox)];
}
+83 -10
View File
@@ -26,8 +26,6 @@ public final class GRPickerBridge: NSObject {
// 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"
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
@@ -177,10 +175,10 @@ public final class GRPickerBridge: NSObject {
// 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)
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 }
@@ -197,15 +195,21 @@ public final class GRPickerBridge: NSObject {
}
}
@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 {
guard let appSupport = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
else { return nil }
dir = appSupport.appendingPathComponent(loveIdentity).path
migrateLegacySaveDirectory()
guard let saveDir = publicSaveDirectory() else { return nil }
dir = saveDir.path
}
let url = URL(fileURLWithPath: dir, isDirectory: true)
ensureDirectory(url)
@@ -217,6 +221,75 @@ public final class GRPickerBridge: NSObject {
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() } }
+63 -3
View File
@@ -27,6 +27,8 @@ NATIVE_SRC = IOS_DIR / "native"
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
@@ -245,19 +247,20 @@ def fail(msg):
sys.exit(1)
def pristine(path: Path) -> str:
def pristine(path: Path, patched_markers=None) -> str:
"""Text of `path` before any of our patching: backed by a `.orig` stash.
The stash is only trusted if it is itself unpatched; that protects
against a stash accidentally taken after an earlier patch run.
"""
patched_markers = tuple(patched_markers or (MARKER, ID_FILE_PICKER))
orig = path.with_suffix(path.suffix + ".orig")
if orig.is_file():
text = orig.read_text()
if MARKER not in text and ID_FILE_PICKER not in text:
if not any(marker in text for marker in patched_markers):
return text
text = path.read_text()
if MARKER in text or ID_FILE_PICKER in text:
if any(marker in text for marker in patched_markers):
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
orig.write_text(text)
@@ -299,6 +302,62 @@ def patch_wrap_system():
"(pickFile/createFile/syncHealthSteps/httpDownload)")
def patch_public_documents():
text = pristine(
APPLE_MM,
("#ifdef LOVE_IOS\n"
"\t\t\tnsdir = NSDocumentDirectory;\n"
"#else\n",),
)
original = (
"\t\tcase USER_DIRECTORY_APPSUPPORT:\n"
"\t\t\tnsdir = NSApplicationSupportDirectory;\n"
"\t\t\tbreak;"
)
replacement = (
"\t\tcase USER_DIRECTORY_APPSUPPORT:\n"
"#ifdef LOVE_IOS\n"
"\t\t\tnsdir = NSDocumentDirectory;\n"
"#else\n"
"\t\t\tnsdir = NSApplicationSupportDirectory;\n"
"#endif\n"
"\t\t\tbreak;"
)
if original not in text:
fail(f"iOS app-support path anchor not found in {APPLE_MM}")
APPLE_MM.write_text(text.replace(original, replacement, 1))
filesystem_text = pristine(
FILESYSTEM_CPP,
("#ifdef LOVE_IOS\n"
"\t\t\tsuffix.clear();\n"
"#else\n",),
)
filesystem_original = (
"\t\tstd::string suffix;\n"
"\t\tif (isFused())\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR) + saveIdentity;\n"
"\t\telse\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR LOVE_APPDATA_FOLDER LOVE_PATH_SEPARATOR) + saveIdentity;"
)
filesystem_replacement = (
"\t\tstd::string suffix;\n"
"#ifdef LOVE_IOS\n"
"\t\t\tsuffix.clear();\n"
"#else\n"
"\t\tif (isFused())\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR) + saveIdentity;\n"
"\t\telse\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR LOVE_APPDATA_FOLDER LOVE_PATH_SEPARATOR) + saveIdentity;\n"
"#endif"
)
if filesystem_original not in filesystem_text:
fail(f"iOS save directory suffix anchor not found in {FILESYSTEM_CPP}")
FILESYSTEM_CPP.write_text(filesystem_text.replace(filesystem_original,
filesystem_replacement, 1))
print("patch_love_src: iOS save directory routed to Documents root")
def patch_pbxproj():
text = pristine(PBXPROJ)
@@ -362,6 +421,7 @@ def main():
if not LOVE_SRC.is_dir():
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
copy_native_files()
patch_public_documents()
patch_wrap_system()
patch_pbxproj()
+33
View File
@@ -217,6 +217,14 @@ apply_ios_branding() {
cp "$OVERLAY_PLIST" "$dest"
}
verify_documents_overlay() {
local sharing in_place
sharing="$(/usr/libexec/PlistBuddy -c 'Print :UIFileSharingEnabled' "$OVERLAY_PLIST" 2>/dev/null || true)"
in_place="$(/usr/libexec/PlistBuddy -c 'Print :LSSupportsOpeningDocumentsInPlace' "$OVERLAY_PLIST" 2>/dev/null || true)"
[ "$sharing" = "true" ] && [ "$in_place" = "true" ] \
|| fail "iOS plist overlay must enable UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace"
}
apply_ios_icon() {
local source="$ROOT/assets/logo/gen1recomp_cover.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
@@ -578,6 +586,25 @@ verify_native_bridge() {
say "native bridge present (pickFile, createFile)"
}
verify_documents_configuration() {
local app="$1"
local plist="$app/Info.plist"
local sharing in_place
[ -f "$plist" ] || fail "built iOS app is missing Info.plist: $plist"
sharing="$(/usr/libexec/PlistBuddy -c 'Print :UIFileSharingEnabled' "$plist" 2>/dev/null || true)"
in_place="$(/usr/libexec/PlistBuddy -c 'Print :LSSupportsOpeningDocumentsInPlace' "$plist" 2>/dev/null || true)"
[ "$sharing" = "true" ] && [ "$in_place" = "true" ] \
|| fail "built iOS app does not expose its Documents folder in $(basename "$app")"
say "public Documents exposure present (file sharing + in-place access)"
}
verify_game_payload() {
local app="$1"
[ -s "$app/game.love" ] \
|| fail "built iOS app is missing game.love: $app"
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -618,6 +645,8 @@ run_xcodebuild() {
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
MARKETING_VERSION="$marketing_version"
CURRENT_PROJECT_VERSION="$project_version"
INFOPLIST_KEY_UIFileSharingEnabled=YES
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
)
@@ -693,6 +722,8 @@ run_xcodebuild() {
fi
fi
verify_documents_configuration "$app"
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
# Byte-compare, never just existence: xcodebuild's incremental Copy Bundle
# Resources can leave a previous build's game.love in a surviving .app, and
@@ -703,6 +734,7 @@ run_xcodebuild() {
cp "$LOVE_FILE" "$app/game.love"
fi
verify_game_payload "$app"
verify_native_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
@@ -774,6 +806,7 @@ install_to_device() {
# --------------------------------------------------------------- main
apply_ios_branding
verify_documents_overlay
apply_ios_icon
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"