From 1b6d2b02dc8750d89846d69e6f2cedb7cd31d813 Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:01:49 -0400 Subject: [PATCH 01/11] fix(roms): rename accuracycoin/README.md so a macOS clone does not collide The repository tracks two directories whose names differ only in case: tests/roms/accuracycoin/ (the runtime AccuracyCoin.nes, its MIT LICENSE and a README) and tests/roms/AccuracyCoin/ (SOURCE_CATALOG.tsv, the sub-test ROMs, the mirror build and a README). On a case-insensitive filesystem (macOS APFS by default, Windows NTFS) the two are one directory, so the two README.md paths fold onto the same file. A fresh `gh repo clone` warns "the following paths have collided", checks out only one of the pair, and the working tree starts dirty: the uppercase README.md shows as modified because it holds the lowercase file's bytes. The two README.md files were the only colliding pair: the LICENSE and .nes files exist on only one side, so they coexist in the folded directory. `git ls-files | tr A-Z a-z | sort | uniq -d` is now empty. The fix renames the lowercase README to RUNTIME.md, which is the smallest change that removes the collision. Merging the two directories was rejected because about 20 sites hard-code tests/roms/accuracycoin/AccuracyCoin.nes (the harness, the netplay determinism tests, pgo_trainer and the trace tools). The new file states why it is not called README.md. The one code comment that named the old path (tests/accuracycoin.rs:64) now points at RUNTIME.md. The cost is that GitHub no longer renders a README when browsing the lowercase directory. On macOS, `git add` with core.ignorecase=true recorded the new file under the uppercase directory. The index entry was written explicitly with `git update-index --cacheinfo` so the lowercase path that Linux CI expects is the one tracked; new files under either directory need the same check. Co-Authored-By: Claude Opus 5.5 --- crates/rustynes-test-harness/tests/accuracycoin.rs | 2 +- tests/roms/accuracycoin/{README.md => RUNTIME.md} | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) rename tests/roms/accuracycoin/{README.md => RUNTIME.md} (94%) diff --git a/crates/rustynes-test-harness/tests/accuracycoin.rs b/crates/rustynes-test-harness/tests/accuracycoin.rs index dda4df48..98310aba 100644 --- a/crates/rustynes-test-harness/tests/accuracycoin.rs +++ b/crates/rustynes-test-harness/tests/accuracycoin.rs @@ -61,7 +61,7 @@ const MIN_PASS_RATE: f64 = 0.60; /// The exact number of `AccuracyCoin` tests the shipped headless build /// passes, re-blessed at the 2026-09 upstream re-sync (upstream `69c8860`; /// the ROM has since moved to `46199ae4`, which changes no verdict — see -/// `tests/roms/accuracycoin/README.md`). +/// `tests/roms/accuracycoin/RUNTIME.md`). /// /// 143 of 144 assigned. The catalog grew 141 -> 144 assigned tests and /// **nothing that passed before stopped passing**: upstream removed no diff --git a/tests/roms/accuracycoin/README.md b/tests/roms/accuracycoin/RUNTIME.md similarity index 94% rename from tests/roms/accuracycoin/README.md rename to tests/roms/accuracycoin/RUNTIME.md index a87b7830..ae4d5fde 100644 --- a/tests/roms/accuracycoin/README.md +++ b/tests/roms/accuracycoin/RUNTIME.md @@ -6,6 +6,10 @@ the uppercase [`../AccuracyCoin/`](../AccuracyCoin/) directory holds the upstream test catalog (TSV) that the diagnostic decoder needs, plus the custom sub-test ROMs. It holds no copy of the battery ROM itself. +This file is `RUNTIME.md`, not `README.md`, because the two directories +differ only in case: on a case-insensitive filesystem (macOS, Windows) they +are one directory, and two `README.md` paths collide on clone. + ## Files | File | Author | License | From 196b92c76a44ac29fd079c10aebf1b487783e6ad Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:20:10 -0400 Subject: [PATCH 02/11] fix(ios): make the Swift app compile against the generated UniFFI surface The first compile of the iOS app on a Mac (Xcode 27.0, iOS 27.0 simulator SDK) failed with 24 errors from two causes. CI never saw either: the iOS job builds the Rust xcframework and runs `xcodegen generate`, but never compiles the Swift target, so both shipped in v2.9.7 green. 1. Two types named `NesButton`. `rustynes-mobile` exports `pub enum NesButton` (`#[derive(uniffi::Enum)]`, the press/release convenience for `NesController::set_button`), which UniFFI emits into ios/Generated/RustyNESCore.swift as `public enum NesButton`. The app declares its own `enum NesButton: UInt8` in NesButtons.swift, the single-bit mask values the touch overlay and gamepad mapper OR into the `set_buttons(port, mask)` wire byte. Both live in one module, so every use was "ambiguous for type lookup" and the declaration an "invalid redeclaration". The app's enum is renamed `NesButtonBit` (it is a bit value, which the old name never said). The bridge's type, and so the Kotlin binding and the Android app, are untouched. The rename is word-bounded, so `NesButtonMask` and the file name `NesButtons.swift` keep their names. The app does not call the bridge's `setButton`; it builds the mask itself. 2. `catch MobileError.missingFdsBios` (AppModel.swift, v2.9.7 FDS BIOS prompt). UniFFI's Swift generator keeps a Rust error enum's variant names verbatim, so the case is `MobileError.MissingFdsBios`, like the existing `RomLoad` / `SaveState` / `InvalidPort` cases. As written, the pattern did not compile; corrected to the generated name. Verified: `xcodebuild -scheme RustyNES -destination 'platform=iOS Simulator,name=iPhone 18 Pro'` reports BUILD SUCCEEDED, and the app launches and runs on the simulator. Co-Authored-By: Claude Opus 5.5 --- ios/RustyNES/AppModel.swift | 2 +- ios/RustyNES/ControlPadLayout.swift | 4 ++-- ios/RustyNES/GameControllerManager.swift | 2 +- ios/RustyNES/MultiTouchControlPad.swift | 4 ++-- ios/RustyNES/NesButtons.swift | 12 ++++++------ ios/RustyNES/TouchControlsOverlay.swift | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index a70e2144..ffd631d3 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -364,7 +364,7 @@ final class AppModel: ObservableObject { netplay.attach(core: core) // Reconcile this game's cloud save-states (pull any newer-remote slots). cloudSaveStates.setCurrentGame(sha: entry.sha) - } catch MobileError.missingFdsBios { + } catch MobileError.MissingFdsBios { // v2.9.7: ask for disksys.rom once, then open this disk again. pendingFdsEntry = entry needsFdsBios = true diff --git a/ios/RustyNES/ControlPadLayout.swift b/ios/RustyNES/ControlPadLayout.swift index 0ebc7a1b..6fb062b8 100644 --- a/ios/RustyNES/ControlPadLayout.swift +++ b/ios/RustyNES/ControlPadLayout.swift @@ -17,7 +17,7 @@ // derive from these same constants, so a resize rescales and remaps them in lockstep // -- they can never desync. // -// The mask bit order is `NesButton` (A=0x01 ... Right=0x80) -- the exact order the +// The mask bit order is `NesButtonBit` (A=0x01 ... Right=0x80) -- the exact order the // core's `Buttons` bitflag uses (see NesButtons.swift). Every input path lands on // the same late-latched bitmask, so determinism is untouched. // @@ -30,7 +30,7 @@ import UIKit /// now-superseded single-touch overlay; the live multi-touch hit test uses the shared /// `ControlPadLayout.hitTest(_:in:)` Android-geometry port directly). struct PadButton: Identifiable { - let button: NesButton + let button: NesButtonBit let frame: CGRect /// The visible glyph ("A"/"B"/"SEL"/"STA"); empty for the D-pad arms. let label: String diff --git a/ios/RustyNES/GameControllerManager.swift b/ios/RustyNES/GameControllerManager.swift index c7eb6075..67262851 100644 --- a/ios/RustyNES/GameControllerManager.swift +++ b/ios/RustyNES/GameControllerManager.swift @@ -84,7 +84,7 @@ enum ControllerInput: String, Codable, CaseIterable, Identifiable { } /// The plain NES button this drives, or nil for turbo/unmapped (handled apart). - var nesButton: NesButton? { + var nesButton: NesButtonBit? { switch self { case .a: return .a case .b: return .b diff --git a/ios/RustyNES/MultiTouchControlPad.swift b/ios/RustyNES/MultiTouchControlPad.swift index 95bfad1f..7664ef59 100644 --- a/ios/RustyNES/MultiTouchControlPad.swift +++ b/ios/RustyNES/MultiTouchControlPad.swift @@ -166,7 +166,7 @@ private enum NesControllerArt { let w = size.width let h = size.height - func has(_ b: NesButton) -> Bool { mask & b.rawValue != 0 } + func has(_ b: NesButtonBit) -> Bool { mask & b.rawValue != 0 } // --- Body + edge, then the near-black central face. The white-plastic borders // are asymmetric like the real shell: thick top, thin bottom, thin sides. @@ -276,7 +276,7 @@ private enum NesControllerArt { let sqW = 0.112 * w let sqH = 0.271 * h let br = 0.046 * w - for (bx, bit) in [(ControlPadLayout.AB_BX, NesButton.b), (ControlPadLayout.AB_AX, NesButton.a)] { + for (bx, bit) in [(ControlPadLayout.AB_BX, NesButtonBit.b), (ControlPadLayout.AB_AX, NesButtonBit.a)] { let cx = bx * w fillRR(&ctx, NesPad.housingW, cx - sqW / 2, abY - sqH / 2, sqW, sqH, 0.035 * h) strokeRR(&ctx, NesPad.housingE, cx - sqW / 2, abY - sqH / 2, sqW, sqH, 0.035 * h, 0.005 * h) diff --git a/ios/RustyNES/NesButtons.swift b/ios/RustyNES/NesButtons.swift index 79407662..d6a23efe 100644 --- a/ios/RustyNES/NesButtons.swift +++ b/ios/RustyNES/NesButtons.swift @@ -25,7 +25,7 @@ import Foundation /// One standard NES controller button as its single-bit mask value. -enum NesButton: UInt8, CaseIterable { +enum NesButtonBit: UInt8, CaseIterable { case a = 0x01 case b = 0x02 case select = 0x04 @@ -36,14 +36,14 @@ enum NesButton: UInt8, CaseIterable { case right = 0x80 } -/// A live 8-bit controller mask (a set of pressed `NesButton`s) for one port. +/// A live 8-bit controller mask (a set of pressed `NesButtonBit`s) for one port. struct NesButtonMask { private(set) var bits: UInt8 = 0 init(bits: UInt8 = 0) { self.bits = bits } /// Press or release a single button, preserving the others. - mutating func set(_ button: NesButton, pressed: Bool) { + mutating func set(_ button: NesButtonBit, pressed: Bool) { if pressed { bits |= button.rawValue } else { @@ -52,7 +52,7 @@ struct NesButtonMask { } /// Whether a button is currently held. - func contains(_ button: NesButton) -> Bool { + func contains(_ button: NesButtonBit) -> Bool { bits & button.rawValue != 0 } @@ -78,8 +78,8 @@ struct NesButtonMask { /// untouched. UNCOMPILED at v2.9.7 as well; see the run sheet. mutating func cancelOpposingDirections(enabled: Bool = true) { guard enabled else { return } - let vertical = NesButton.up.rawValue | NesButton.down.rawValue - let horizontal = NesButton.left.rawValue | NesButton.right.rawValue + let vertical = NesButtonBit.up.rawValue | NesButtonBit.down.rawValue + let horizontal = NesButtonBit.left.rawValue | NesButtonBit.right.rawValue if bits & vertical == vertical { bits &= ~vertical } if bits & horizontal == horizontal { bits &= ~horizontal } } diff --git a/ios/RustyNES/TouchControlsOverlay.swift b/ios/RustyNES/TouchControlsOverlay.swift index 2d524702..87c0d1ea 100644 --- a/ios/RustyNES/TouchControlsOverlay.swift +++ b/ios/RustyNES/TouchControlsOverlay.swift @@ -9,7 +9,7 @@ // this in `GameView` with `MultiTouchControlPad` (a UIView-backed true multi-touch // responder). Both share `ControlPadLayout`, so the regions stay identical. // -// The mask bit order is NesButton (A=0x01 ... Right=0x80) — the exact order the +// The mask bit order is NesButtonBit (A=0x01 ... Right=0x80) — the exact order the // core's `Buttons` bitflag uses (see NesButtons.swift). Touch input flows through // the same late-latched mask path, so determinism is untouched. // From c6ca71df30e2ab2ab03a406172169a7805024046 Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:20:10 -0400 Subject: [PATCH 03/11] fix(ios): stop XcodeGen overwriting the hand-written Info.plist ios/project.yml gave the RustyNES target an `info: path: RustyNES/Info.plist` block with no `info.properties`. XcodeGen treats `info.path` as an output: it GENERATES that file from `properties`, so every run of scripts/build-ios-xcframework.sh (which ends in `xcodegen generate`) replaced the tracked 65-key plist with a bare template. The overwrite dropped CFBundleDocumentTypes (the .nes/.fds/.nsf/.rns/.rnm document types), UTImportedTypeDeclarations, UIAppFonts, CFBundleDisplayName, CFBundleLocalizations, UILaunchScreen, the orientation lists and CADisableMinimumFrameDurationOnPhone (the 120 Hz ProMotion unlock). It showed up as -232 lines of `git diff` after a local build. In CI the job runs on a throwaway checkout, so any Xcode build or fastlane archive there ran on the stripped plist and nobody saw the diff. The block is removed. `INFOPLIST_FILE: RustyNES/Info.plist` in the target's settings already points Xcode at the tracked file, which is all the project needs. A comment at the old site records why the block must not come back. Verified: after `xcodegen generate --spec ios/project.yml`, `git status` shows Info.plist unmodified, and the app builds and launches. Co-Authored-By: Claude Opus 5.5 --- ios/project.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ios/project.yml b/ios/project.yml index 936c6af3..d9b25288 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -100,8 +100,11 @@ targets: # GameKit — opt-in Game Center sign-in + access point (GameCenterModel.swift). - sdk: ReplayKit.framework - sdk: GameKit.framework - info: - path: RustyNES/Info.plist + # No `info:` block: XcodeGen GENERATES the file an `info.path` names, and + # with no `info.properties` it overwrote the hand-written Info.plist with a + # bare template on every run (dropping the ROM document types, the UTI + # declarations, the fonts and the orientations). `INFOPLIST_FILE` above + # already points the target at the tracked plist. schemes: RustyNES: From a8bd5cf8b7b700ed31e88044e3ba3b6de565f11e Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:20:10 -0400 Subject: [PATCH 04/11] fix(ios): build the Rust xcframework for the app's iOS 17.0 floor scripts/build-ios-xcframework.sh never set IPHONEOS_DEPLOYMENT_TARGET. rustc and the `cc` crate both fall back to the installed SDK's version when it is unset, so with Xcode 27 every C object in librustynes_ios.a was compiled for iOS 27.0: Lua 5.5 (lua-src, via mlua), rcheevos and ring. The app's deployment target is 17.0 (ios/project.yml), and the link printed one "object file ... was built for newer 'iOS-simulator' version (27.0) than being linked (17.0)" warning per object: 76 in one build. The warning is a real hazard, not noise. Such an object may reference symbols that only exist on newer iOS, and on an iOS 17-26 device that is a launch-time dyld failure. The script now exports IPHONEOS_DEPLOYMENT_TARGET=17.0, kept in step with project.yml by a comment, and a caller can still override it from the environment. A clean build picks it up. An incremental one does not fully: ring and rcheevos rebuild, but lua-src's build script does not declare the variable as a rerun trigger, so a warm target/ keeps its 27.0 Lua objects until `cargo clean -p lua-src` for each iOS target. Verified after that clean: 0 "built for newer" warnings, BUILD SUCCEEDED. Co-Authored-By: Claude Opus 5.5 --- scripts/build-ios-xcframework.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/build-ios-xcframework.sh b/scripts/build-ios-xcframework.sh index 341d8864..50cf93ae 100755 --- a/scripts/build-ios-xcframework.sh +++ b/scripts/build-ios-xcframework.sh @@ -36,6 +36,14 @@ TARGET_DEVICE="aarch64-apple-ios" TARGET_SIM_ARM="aarch64-apple-ios-sim" TARGET_SIM_X86="x86_64-apple-ios" +# The minimum iOS the archive is compiled for, matching `deploymentTarget` in +# ios/project.yml. rustc and the `cc` crate (which compiles the bundled C: Lua, +# rcheevos, ring) both read it. Unset, the C objects default to the installed +# SDK's version, so the app (17.0) linked objects "built for newer iOS-simulator +# version (27.0)" -- a warning per object, and on an older device a crash on any +# newer symbol those objects reference. +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-17.0}" + echo "==> Installing iOS Rust targets" rustup target add "${TARGET_DEVICE}" "${TARGET_SIM_ARM}" "${TARGET_SIM_X86}" From 3510eb2b3b3410b6d6e5e50417af17b77556f28f Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:20:10 -0400 Subject: [PATCH 05/11] fix(ios): no CloudKit container without the entitlement (launch crash) An iOS build without the iCloud container entitlement aborted at launch: *** Terminating app due to uncaught exception 'CKException', reason: 'containerIdentifier can not be nil' ... CloudSaveStateSync.refreshAccount() <- CloudSaveStateSync.start() AppModel calls `cloudSaveStates.start()` at launch, and start() ran `CKContainer.default().accountStatus()` unconditionally, even though save-state sync is opt-in and off by default. `CKContainer.default()` derives its id from the signed `icloud-container-identifiers` entitlement and raises an Objective-C exception when there is none, which Swift's `try?` cannot catch. Any unsigned simulator build hit it, and so would a sideload whose profile lacks the iCloud capability. The file header and RustyNES.entitlements both promised it "gracefully no-op[s]" until provisioned; it did not. Two changes: * start() and refreshAccount() return early when sync is disabled, so a default install never touches CloudKit. * The container is created only when the binary carries the entitlement: `container` and `database` are now optional, and every call site treats nil like a signed-out account, so the slot reports unavailable and the local save is never blocked. Creating the container explicitly does not avoid the trap: `CKContainer(identifier:)` was measured to stop in a `brk` inside its initialiser on the iOS 27 simulator. So the entitlement is probed first, with public API only (`CloudKitEntitlement.isPresent`): - simulator: the main executable's __TEXT,__entitlements section, which Xcode links in from the Simulated.xcent and which an unsigned build lacks (getsectiondata on _dyld_get_image_header(0)); - device: the Entitlements dictionary of embedded.mobileprovision, read from the CMS envelope by its plist delimiters. App Store and TestFlight builds carry no embedded profile and are always signed with the capability, so a missing profile counts as entitled. Measured on the iPhone 18 Pro simulator (iOS 27.0). Each of four builds was installed fresh and launched with `cloudSaveStates` forced via `defaults write`: unsigned, sync off -> runs, no crash report (was: CKException) unsigned, sync on -> runs, no crash report (was: brk in init) ad-hoc, sync off -> runs, no crash report ad-hoc, sync on -> runs, no crash report A temporary NSLog probe, removed before this commit, showed the unsigned build reading isPresent=false and the ad-hoc build isPresent=true, the latter reaching `accountStatus()` without trapping (accountAvailable=false: the simulator has no iCloud account). The device branch, embedded.mobileprovision, is unexercised: no device was available, and it needs a check on hardware. Co-Authored-By: Claude Opus 5.5 --- ios/RustyNES/CloudSaveStateSync.swift | 99 ++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/ios/RustyNES/CloudSaveStateSync.swift b/ios/RustyNES/CloudSaveStateSync.swift index f52a2e7e..af067358 100644 --- a/ios/RustyNES/CloudSaveStateSync.swift +++ b/ios/RustyNES/CloudSaveStateSync.swift @@ -45,6 +45,7 @@ import CloudKit import Foundation +import MachO @MainActor final class CloudSaveStateSync: ObservableObject { @@ -91,8 +92,10 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Lifecycle wiring (driven by AppModel) - /// Kick off an account check at launch (no game yet). Safe to call when disabled. + /// Kick off an account check at launch (no game yet). Safe to call when disabled: + /// sync is opt-in, so a disabled sync never touches CloudKit at all. func start() { + guard enabled else { return } Task { await refreshAccount() } } @@ -128,7 +131,11 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Account availability private func refreshAccount() async { - let status = try? await CKContainer.default().accountStatus() + guard enabled else { + accountAvailable = false + return + } + let status = try? await Self.container?.accountStatus() accountAvailable = (status == .available) } @@ -176,9 +183,9 @@ final class CloudSaveStateSync: ObservableObject { // Re-check the account live rather than trusting a possibly-stale cached flag: // the initial async account check may not have finished when a save fires, which // would wrongly skip the upload and leave the slot `localOnly`. - let status = try? await CKContainer.default().accountStatus() + let status = try? await Self.container?.accountStatus() accountAvailable = (status == .available) - guard accountAvailable else { return nil } + guard accountAvailable, let database = Self.database else { return nil } let urls = saveStates.fileURLs(sha: sha, slot: slot) let meta = saveStates.slot(sha: sha, index: slot) guard !meta.isEmpty else { return nil } @@ -194,7 +201,7 @@ final class CloudSaveStateSync: ObservableObject { // fails this save instead of being lost. (A missing record, or a fetch // that fails offline, starts a new record; saving a new record over an // existing one also fails under that policy.) - let existing = try? await Self.database.record(for: id) + let existing = try? await database.record(for: id) if let existing, let remoteSaved = existing["savedAt"] as? Date, remoteSaved > savedAt { return nil } @@ -214,7 +221,7 @@ final class CloudSaveStateSync: ObservableObject { } do { - let (saved, _) = try await Self.database.modifyRecords( + let (saved, _) = try await database.modifyRecords( saving: [record], deleting: [], savePolicy: .ifServerRecordUnchanged, atomically: true ) // The call succeeds as a whole even when this record's save failed @@ -242,9 +249,9 @@ final class CloudSaveStateSync: ObservableObject { /// Remove a slot's cloud record (best-effort) when the user deletes it locally. func delete(sha: String, slot: Int) { states[slot] = nil - guard enabled else { return } + guard enabled, let database = Self.database else { return } Task { - _ = try? await Self.database.modifyRecords( + _ = try? await database.modifyRecords( saving: [], deleting: [recordID(sha: sha, slot: slot)], savePolicy: .allKeys, atomically: true ) @@ -258,7 +265,7 @@ final class CloudSaveStateSync: ObservableObject { func reconcile(sha: String) async { guard enabled else { return } await refreshAccount() - guard accountAvailable else { + guard accountAvailable, let database = Self.database else { markAllUnavailable() return } @@ -266,7 +273,7 @@ final class CloudSaveStateSync: ObservableObject { let ids = (0..] do { - results = try await Self.database.records(for: ids) + results = try await database.records(for: ids) } catch { // Offline / transient: keep the local-derived states, don't churn the UI. return @@ -347,7 +354,21 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Record identity - private static var database: CKDatabase { CKContainer.default().privateCloudDatabase } + /// The CloudKit container, or `nil` when this binary is not entitled to one. + /// + /// CloudKit raises an uncatchable trap -- not a `CKError` -- when the app + /// creates a container without the `com.apple.developer.icloud-container- + /// identifiers` entitlement: `CKContainer.default()` throws a `CKException` + /// ("containerIdentifier can not be nil") and `CKContainer(identifier:)` stops + /// in a `brk` inside its initialiser (both measured on the iOS 27 simulator). + /// An unsigned simulator build, or a sideload whose profile lacks the iCloud + /// capability, crashed at launch that way. So no container is created unless + /// the entitlement is present, and every CloudKit call site below treats a + /// `nil` container as "unavailable", exactly like a signed-out account. + private static let container: CKContainer? = + CloudKitEntitlement.isPresent ? CKContainer.default() : nil + + private static var database: CKDatabase? { container?.privateCloudDatabase } private func recordID(sha: String, slot: Int) -> CKRecord.ID { CKRecord.ID(recordName: "state-\(sha)-\(slot)") @@ -359,3 +380,59 @@ final class CloudSaveStateSync: ObservableObject { return Int(id.recordName[id.recordName.index(after: dash)...]) } } + +// MARK: - Entitlement probe + +/// Whether this binary carries the CloudKit container entitlement, read with public +/// APIs only (iOS exposes no call that returns an app's own entitlements). +/// +/// * Simulator: Xcode links the entitlements into the main executable's +/// `__TEXT,__entitlements` section ("Simulated.xcent"); an unsigned build +/// (`CODE_SIGNING_ALLOWED=NO`) has no such section. +/// * Device: a development or ad-hoc build embeds its provisioning profile, whose +/// `Entitlements` dictionary is what the signature was allowed to claim. App Store +/// and TestFlight builds carry no `embedded.mobileprovision`, and are always +/// signed with the capability, so a missing profile counts as entitled. +enum CloudKitEntitlement { + private static let key = "com.apple.developer.icloud-container-identifiers" + + static let isPresent: Bool = { + #if targetEnvironment(simulator) + guard let entitlements = simulatorEntitlements() else { return false } + #else + guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") else { + return true + } + guard let entitlements = profileEntitlements(at: url) else { return false } + #endif + return !((entitlements[key] as? [Any])?.isEmpty ?? true) + }() + + #if targetEnvironment(simulator) + private static func simulatorEntitlements() -> [String: Any]? { + guard let header = _dyld_get_image_header(0) else { return nil } + var size: UInt = 0 + let raw = header.withMemoryRebound(to: mach_header_64.self, capacity: 1) { + getsectiondata($0, "__TEXT", "__entitlements", &size) + } + guard let raw, size > 0 else { return nil } + return plist(Data(bytes: raw, count: Int(size))) + } + #else + /// The profile is a CMS envelope around an XML plist; the plist is read out of + /// it by its delimiters rather than by verifying the signature, which is the + /// kernel's job, not this probe's. + private static func profileEntitlements(at url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url), + let start = data.range(of: Data("".utf8), in: start.lowerBound.. [String: Any]? { + (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any] + } +} From bc828a9bd83e3991800c1581e08b773dab7a2ec4 Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:33:19 -0400 Subject: [PATCH 06/11] fix(ios): start the frame loop even when the drawable is unsized (black screen) Every game opened to a black picture under a working controller overlay: the ROM loaded, but no frame ever ran or presented. MetalGameView's Coordinator.attachAndStart() is called from makeUIView, before SwiftUI lays the MTKView out, so `view.drawableSize` is 0 x 0 there and the renderer build is deferred. The deferred build had two retry paths, and neither could run: * `step(_:)`, the CADisplayLink callback, rebuilds when it sees a real drawable size. But the display link was created only at the END of a successful attachAndStart(), so with the build deferred there was no link and `step` never ran. * `mtkView(_:drawableSizeWillChange:)` was an intentional no-op. Calling attachAndStart() from it would not help either, because `view.drawableSize` still reads 0 x 0 inside the callback. The only remaining retry was appWillEnterForeground, so a background and foreground round trip was the one way a game ever started. Measured on the iPhone 18 Pro simulator (iOS 27.0) with temporary NSLog probes (not committed). Before the fix: attachAndStart drawableSize=(0.0, 0.0) drawableSizeWillChange (1206.0, 2622.0) (no step tick, no rustynes_ios_gfx_init call, ever) After it: attachAndStart drawableSize=(0.0, 0.0) <- makeUIView, deferred step #1 attachAndStart drawableSize=(1206.0, 2622.0) gfx_init 1206x2622 -> ok step #61, step #121, ... <- 60 Hz The fix starts the display link first in attachAndStart(), before the size guard. `EmulatorCore.tick()` returns early until the renderer exists, so ticking before the build is free, and `step`'s existing resize branch completes the build on the first tick with a real size. Because the link can now exist before the renderer does, the foreground handler's not-yet-attached branch also unpauses it. Otherwise a background trip before the first layout would leave it paused, since appDidEnterBackground pauses it. This was a host-side bug. The wgpu/Metal renderer in rustynes-ios builds and presents on the simulator once it is called. Verified by opening nestest.nes, dpcmletterbox.nes and AccuracyCoin.nes through `simctl openurl`: each renders its title screen. Co-Authored-By: Claude Opus 5.5 --- ios/RustyNES/MetalGameView.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/ios/RustyNES/MetalGameView.swift b/ios/RustyNES/MetalGameView.swift index c03c02df..57872245 100644 --- a/ios/RustyNES/MetalGameView.swift +++ b/ios/RustyNES/MetalGameView.swift @@ -109,11 +109,23 @@ struct MetalGameView: UIViewRepresentable { } /// Build the renderer for the current drawable and start the loop. + /// + /// The display link starts FIRST, unconditionally. `makeUIView` calls this + /// before SwiftUI has laid the view out, so the drawable is normally 0 x 0 + /// here and the renderer build is deferred -- and the code that retries it + /// is `step`'s resize branch, which runs off this same link. Starting the + /// link only after a successful build (as through v2.9.7) meant a deferred + /// build was never retried: no renderer, no frames, a black game view under + /// working controls. (`mtkView(_:drawableSizeWillChange:)` cannot do the + /// retry: `view.drawableSize` still reads 0 x 0 inside it.) `tick()` is a + /// no-op until the renderer exists, so the early link costs nothing. func attachAndStart() { guard let view, !attached else { return } + startDisplayLink() let size = view.drawableSize guard size.width > 0, size.height > 0 else { - // The drawable is not sized yet; defer to the first delegate call. + // The drawable is not sized yet; `step` retries on the first tick + // that sees a real size. return } let ptr = Unmanaged.passUnretained(view).toOpaque() @@ -121,7 +133,6 @@ struct MetalGameView: UIViewRepresentable { lastDrawableSize = size attached = true emulator.start() - startDisplayLink() } private func startDisplayLink() { @@ -233,7 +244,10 @@ struct MetalGameView: UIViewRepresentable { @objc private func appWillEnterForeground() { guard attached else { // The renderer was never built (drawable was 0 at makeUIView); try now. + // The link already exists (attachAndStart starts it first) and was + // paused on background, so resume it or no tick ever retries. attachAndStart() + displayLink?.isPaused = false return } if let view { From 37b1cc5bc5d93c7bb92aa0a5dd67ce1e926b633e Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:41:52 -0400 Subject: [PATCH 07/11] ci(ios): build the Swift app at release time, and make ios.yml run at all Two gaps, found while building the iOS app on a Mac for the first time: 1. Nothing in CI compiled the Swift app. ios.yml builds the Rust xcframework and runs `xcodegen generate`, but never runs xcodebuild on the Swift target. So v2.9.7 shipped an app that did not compile: 24 errors, from a `NesButton` collision with the UniFFI-generated type and a mis-cased `MobileError.MissingFdsBios` (fixed in 196b92c7). 2. ios.yml had not run for a release since v2.3.8. `gh run list --workflow ios.yml` shows its last tag run on 2026-08-20 (v2.3.8), and nothing since but a skipped cron. Releases are now cut by release-auto.yml, which pushes the tag with GITHUB_TOKEN, and GitHub's recursion guard stops such a tag firing `on: push: tags`. release-auto already works around this for release.yml by calling it (`workflow_call`), but never did so for ios.yml. So v2.3.9 through v2.9.7, about forty releases, built no iOS host at all. Changes: * ios.yml gains a `workflow_call` trigger with a `tag` input (the same shape as release.yml) and checks out `inputs.tag || github.ref`. release-auto.yml gains an `ios` job that calls it for every release it cuts, with `secrets: inherit` so the existing TestFlight upload works once signing is provisioned. The "Detect iOS signing secrets" step still skips the upload when the secrets are absent. The job runs alongside `build`, so a failure marks the release run red without touching the attached binaries. * After the xcframework step, ios.yml now: - fails if the script modified any tracked file (`git diff --exit-code`). It rewrote ios/RustyNES/Info.plist on every run until c6ca71df, and on a throwaway checkout nothing noticed; - runs `xcodebuild build` of the RustyNES scheme for `generic/platform=iOS Simulator` with CODE_SIGNING_ALLOWED=NO. No device or certificate is involved, so it runs whether or not signing is provisioned; - uploads the xcodebuild log as an artifact on failure. * docs/ios.md: the CI paragraph said "gated to tag pushes", which has been untrue in practice since v2.3.9. It now describes the release-auto call and the Swift build. Cost, by the maintainer's direction (2026-10-02): macOS jobs run at release time only, never on PRs, pushes to main or the weekly cron. ci.yml is untouched. The trade-off is that the check cannot block a release: release-auto creates the tag and GitHub Release before calling ios.yml, so a broken app still ships, and the run turns red afterwards. For a pre-merge answer, dispatch "iOS" by hand on the release branch. Verified locally (Xcode 27.0, macOS arm64): actionlint passes on both workflow files. The new steps run as written: the script succeeds, `git diff --exit-code` over ios/ and scripts/ is clean, and the generic simulator build reports BUILD SUCCEEDED with an x86_64 + arm64 app. The workflow itself has not run on GitHub yet; its first real run is the next release, or a manual dispatch. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ios.yml | 60 ++++++++++++++++++++++++++++++ .github/workflows/release-auto.yml | 21 +++++++++++ docs/ios.md | 16 ++++++-- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 739a06b7..3a26dd23 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -6,11 +6,33 @@ name: iOS # is tag- (and manual-) gated because macOS runner minutes bill ~10x Linux — the # same gating posture as android.yml's heavier jobs and release.yml. Accuracy is # never gated on a device toolchain. +# +# Beyond the TestFlight upload, every run also COMPILES THE SWIFT APP for the iOS +# Simulator (the "Build the app" step). Nothing else in CI builds the Swift +# target, so until v2.9.7 the app could stop compiling with every check green -- +# and it had. The first Xcode build on a Mac after v2.9.7 failed with 24 errors. +# That step is release-time only, like the rest of this workflow, on purpose: +# macOS minutes are the cost this file's gating exists to contain. For an +# earlier answer, run this workflow by hand (`workflow_dispatch`) on a branch +# before cutting a release. on: push: tags: - 'v*' workflow_dispatch: + # Invoked by `release-auto.yml` for every release it cuts. This is the trigger + # that actually fires: the auto-release tag is pushed with the built-in + # GITHUB_TOKEN, which does NOT trigger `on: push: tags` (GitHub's recursion + # guard, the same reason release-auto calls release.yml directly). The tag + # trigger above has therefore not run since v2.3.8 (2026-08-20). Every + # release from v2.3.9 to v2.9.7 was auto-tagged, and none of them built the + # iOS host at all. + workflow_call: + inputs: + tag: + description: "Release tag to build (e.g. v2.9.8)." + required: true + type: string # v1.9.1 "Patch" — TestFlight build-refresh cadence. A TestFlight build expires # 90 days after upload; re-build + re-upload at 06:00 UTC on the 1st of every # other month (~60-day cadence) so external testers never lose access between @@ -43,8 +65,11 @@ jobs: # so an un-provisioned repo must not paint every release tag push red. if: ${{ github.event_name != 'schedule' || vars.IOS_SIGNING_READY == 'true' }} steps: + # The release tag when called by release-auto (it exists by then, as + # release-auto creates it first); otherwise the triggering ref. - uses: actions/checkout@v7 with: + ref: ${{ inputs.tag || github.ref }} persist-credentials: false # v2.0.7 "Trim" — App Store submission floor. From 2026-04-28 every App Store @@ -111,6 +136,41 @@ jobs: - name: Build xcframework + generate project run: ./scripts/build-ios-xcframework.sh + # The script must leave the tracked tree untouched. Until v2.9.8 it + # rewrote the hand-written ios/RustyNES/Info.plist on every run (an + # `info:` block in project.yml made XcodeGen treat the plist as output), + # and on a throwaway CI checkout nobody saw it. + - name: Build left the tracked tree unchanged + run: git diff --exit-code + + # Compile and link the Swift app against the xcframework and the freshly + # generated UniFFI bindings. Unsigned, generic simulator: no device and + # no certificate needed, so it runs whether or not the signing secrets + # exist. This catches a Swift / UniFFI mismatch, for example a Rust type + # renamed or added in rustynes-mobile, at release time instead of on the + # first developer's Mac. It proves the app builds, not that it runs; that + # is the device run sheet's job. + - name: Build the app (iOS Simulator, unsigned) + working-directory: ios + run: | + set -o pipefail + xcodebuild build \ + -project RustyNES.xcodeproj \ + -scheme RustyNES \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + | tee xcodebuild.log + + - name: Upload the xcodebuild log + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: ios-app-xcodebuild-log + path: ios/xcodebuild.log + if-no-files-found: ignore + # Detect whether the maintainer-provisioned signing secrets exist. They are a # documented manual carryover (docs/ios.md "Maintainer-manual carryovers"): # the App Store Connect API key (ASC_*) + the fastlane match repo (MATCH_*). diff --git a/.github/workflows/release-auto.yml b/.github/workflows/release-auto.yml index 7ba85b53..8bd0a23b 100644 --- a/.github/workflows/release-auto.yml +++ b/.github/workflows/release-auto.yml @@ -247,3 +247,24 @@ jobs: uses: ./.github/workflows/release.yml with: tag: ${{ needs.prepare.outputs.tag }} + + # The iOS host for the same release: the xcframework, the Swift app compiled + # for the simulator, and, once signing is provisioned, the TestFlight upload. + # Called directly for the same reason as `build` above: the tag this run + # pushed with GITHUB_TOKEN never fires ios.yml's own `push: tags` trigger, + # which is why no release from v2.3.9 to v2.9.7 built the iOS app at all. + # It runs alongside `build`, not after it, and a failure here marks the + # release run red without touching the binaries `build` attaches. + ios: + name: iOS host (xcframework + Swift build) + needs: prepare + if: needs.prepare.outputs.should_release == 'true' + permissions: + contents: read + uses: ./.github/workflows/ios.yml + with: + tag: ${{ needs.prepare.outputs.tag }} + # The App Store Connect + fastlane match secrets ios.yml reads for the + # TestFlight upload. Its "Detect iOS signing secrets" step skips the upload + # when they are absent. + secrets: inherit diff --git a/docs/ios.md b/docs/ios.md index 321aadbf..b4ba913e 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -252,10 +252,18 @@ device `.a` (`cargo run -p rustynes-mobile --bin uniffi-bindgen -- generate --library … --language swift`; rename the modulemap to `module.modulemap`) -> assemble the headers dir (`rustynes_mobileFFI.h` + `rustynes_ios.h` + `module.modulemap`) -> `xcodebuild -create-xcframework` -> `xcodegen generate`. -CI (`.github/workflows/ios.yml`) runs this on `macos-latest`, **gated to tag -pushes (`v*`) + manual dispatch** because macOS minutes bill ~10x — the host -`ci.yml` remains the accuracy / determinism authority and is never gated on a -device toolchain. `fastlane` (`match` read-only signing + `gym` + `pilot`) uploads +CI (`.github/workflows/ios.yml`) runs this on `macos-latest`, **at release time +only** because macOS minutes bill ~10x — the host `ci.yml` remains the accuracy / +determinism authority and is never gated on a device toolchain. In practice +"release time" means `release-auto.yml`, which calls `ios.yml` (`workflow_call`) +for every release it cuts. Its `push: tags` trigger never fires for those, +because the auto-release tag is pushed with `GITHUB_TOKEN`. So from v2.3.9 to +v2.9.7 no release built the iOS host, until the call was added at v2.9.8. +Manual dispatch remains for an on-demand run, for example on a branch before a +release. Each run then **compiles the Swift app** for the generic iOS Simulator, +unsigned. It is the only place CI builds the Swift target, and v2.9.7 shipped +with 24 Swift compile errors because nothing did. The run also fails if the +script modified any tracked file. `fastlane` (`match` read-only signing + `gym` + `pilot`) uploads to TestFlight via an App Store Connect API key. The upload is **gated on the signing secrets being present** (a "Detect iOS signing secrets" step): until they are provisioned, the xcframework build still runs (proving the iOS host compiles) From 5d88fcb4fbf14822b579878a2a9d651e9e82048a Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:42:19 -0400 Subject: [PATCH 08/11] docs(changelog): record the iOS fixes and the release-time iOS CI under Unreleased Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6160c314..1d409ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,39 @@ cycle-accurate core later replaced. ## [Unreleased] +### Fixed + +- **The iOS app compiles again.** The first Xcode build on a Mac (Xcode 27) + failed with 24 errors in v2.9.7's Swift. The app's own `NesButton` collided + with the type UniFFI generates from `rustynes-mobile`, now renamed + `NesButtonBit`. The FDS BIOS prompt also caught `MobileError.missingFdsBios`, + but the generated case is `MissingFdsBios`. +- **iOS games no longer open to a black screen.** The frame loop started only + after the renderer was built, and the only retry of a build deferred at + first layout ran off that same loop, so no frame ever ran until the app was + backgrounded and foregrounded. The loop now starts first. +- **No iOS launch crash without the iCloud capability.** Save-state sync is + opt-in, but the app checked the iCloud account at launch anyway, and + `CKContainer` traps uncatchably in a build without the container + entitlement. CloudKit is now untouched while sync is off, and a container is + created only when the entitlement is present. +- **`scripts/build-ios-xcframework.sh` no longer overwrites + `ios/RustyNES/Info.plist`** (an `info:` block in `ios/project.yml` made + XcodeGen regenerate it, dropping the ROM document types, fonts and + orientations). It also builds the Rust and C code for the app's iOS 17.0 + floor instead of the installed SDK's version. +- **A macOS clone no longer starts dirty.** `tests/roms/accuracycoin/README.md` + and `tests/roms/AccuracyCoin/README.md` were one file on a case-insensitive + filesystem; the lowercase one is now `RUNTIME.md`. + +### Changed + +- **CI builds the iOS app at release time.** `ios.yml` now compiles the Swift + app for the iOS Simulator, and fails if the build script modified a tracked + file. `release-auto.yml` calls `ios.yml` for every release: its tag trigger + never fires for auto-pushed tags, so no release from v2.3.9 to v2.9.7 had + built the iOS host. macOS jobs still never run on pull requests. + ## [2.9.7] - 2026-09-30 - "Tandem" (the desktop's features on the web and on phones, full release binaries, and an A12 fix found by real games) The eighth release of the v2.9.x line and the fourth of the line to v3.0.0: From af092df8c43449299eb40697f5a729345cf1250e Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:49:10 -0400 Subject: [PATCH 09/11] fix(ios): log CloudKit delete and reconcile-fetch failures (review) PR #578 review (Antigravity, blocking): two CloudKit paths in CloudSaveStateSync dropped their errors with no record, against the project rule on swallowed errors. The same file already logs every upload failure with NSLog. * delete(sha:slot:) ran `_ = try? await database.modifyRecords(...)`. It now logs a thrown error and a per-record failure, because modifyRecords reports a per-record failure inside its result, not by throwing. The exception is `.unknownItem`, which only means the slot was never uploaded and is the expected case for a local-only slot. The call stays best-effort: the local delete never waits on it. * reconcile(sha:)'s fetch `catch { return }` now logs before returning. The behaviour is unchanged (an offline or transient failure keeps the locally derived slot states), but the failure is recorded. Both lines predate this PR, which only renamed `Self.database` to the unwrapped `database` on them. Fixed here because the review is right and the fix is local. Verified: `xcodebuild build` for generic/platform=iOS Simulator, unsigned, reports BUILD SUCCEEDED with no warnings in this file. Co-Authored-By: Claude Opus 5.5 --- ios/RustyNES/CloudSaveStateSync.swift | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/ios/RustyNES/CloudSaveStateSync.swift b/ios/RustyNES/CloudSaveStateSync.swift index af067358..bedbd79e 100644 --- a/ios/RustyNES/CloudSaveStateSync.swift +++ b/ios/RustyNES/CloudSaveStateSync.swift @@ -247,14 +247,29 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Delete (on slot clear) /// Remove a slot's cloud record (best-effort) when the user deletes it locally. + /// + /// Best-effort means the local delete never waits on or fails with the cloud + /// one, not that a failure goes unrecorded: it is logged like an upload + /// failure. `modifyRecords` reports a per-record failure in its result rather + /// than by throwing, so both are checked. `.unknownItem` is not logged: it + /// only means the slot was never uploaded. func delete(sha: String, slot: Int) { states[slot] = nil guard enabled, let database = Self.database else { return } + let id = recordID(sha: sha, slot: slot) Task { - _ = try? await database.modifyRecords( - saving: [], deleting: [recordID(sha: sha, slot: slot)], - savePolicy: .allKeys, atomically: true - ) + do { + let (_, deleted) = try await database.modifyRecords( + saving: [], deleting: [id], + savePolicy: .allKeys, atomically: true + ) + if case .failure(let error) = deleted[id], + (error as? CKError)?.code != .unknownItem { + NSLog("RustyNES: iCloud slot \(slot) not deleted: \(error)") + } + } catch { + NSLog("RustyNES: iCloud slot \(slot) delete failed: \(error)") + } } } @@ -275,7 +290,9 @@ final class CloudSaveStateSync: ObservableObject { do { results = try await database.records(for: ids) } catch { - // Offline / transient: keep the local-derived states, don't churn the UI. + // Offline / transient: keep the local-derived states, don't churn the + // UI. Logged, not shown: a fetch failure changes nothing on screen. + NSLog("RustyNES: iCloud reconcile fetch failed: \(error)") return } From 9c3fbd8522259527e5756f3a9aa75e0fd0829fc1 Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 00:52:00 -0400 Subject: [PATCH 10/11] docs(ios): state when ios.yml runs accurately, and drop the unreleased v2.9.8 (review) PR #578 review (Copilot, 3 threads), all correct: * "Release time only" was false. ios.yml keeps its pre-existing schedule trigger, the every-other-month TestFlight refresh (builds expire after 90 days), and that cron runs the same macOS job, including the new Swift build, whenever the IOS_SIGNING_READY repo variable is "true". It is dormant today: `gh variable list` shows no variables, and its last cron run (2026-09-01) was skipped. The cost constraint behind this PR keeps macOS jobs off pull requests, off pushes to main and off ci.yml's weekly cron. The refresh cron is TestFlight's renewal mechanism and stays. The workflow header comment and docs/ios.md now say exactly that: never on a PR; for a release, by hand, and on the dormant refresh cron. * "until the call was added at v2.9.8" and "Until v2.9.8 it rewrote ..." presented an unreleased version as released (the current release is v2.9.7, and the change sits under CHANGELOG [Unreleased]). The docs now say the call is unreleased and first runs on the next release, and the workflow comment says "Through v2.9.7". The `e.g. v2.9.8` in the workflow_call input description is an example value and stays. actionlint passes on ios.yml. markdownlint-cli 0.49.1 passes on docs/ios.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ios.yml | 13 ++++++++----- docs/ios.md | 20 +++++++++++--------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 3a26dd23..5d87acc4 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -11,10 +11,13 @@ name: iOS # Simulator (the "Build the app" step). Nothing else in CI builds the Swift # target, so until v2.9.7 the app could stop compiling with every check green -- # and it had. The first Xcode build on a Mac after v2.9.7 failed with 24 errors. -# That step is release-time only, like the rest of this workflow, on purpose: -# macOS minutes are the cost this file's gating exists to contain. For an -# earlier answer, run this workflow by hand (`workflow_dispatch`) on a branch -# before cutting a release. +# That step runs only when this workflow does, on purpose: macOS minutes are the +# cost this file's gating exists to contain. This workflow runs for a release +# (the `workflow_call` from release-auto.yml), by hand, and on the +# every-other-month TestFlight refresh cron, which is skipped unless the +# `IOS_SIGNING_READY` repo variable is "true" (unset as of 2026-10-02, so the +# cron is dormant). It never runs on a pull request. For an answer before a +# release, run it by hand (`workflow_dispatch`) on the release branch. on: push: tags: @@ -136,7 +139,7 @@ jobs: - name: Build xcframework + generate project run: ./scripts/build-ios-xcframework.sh - # The script must leave the tracked tree untouched. Until v2.9.8 it + # The script must leave the tracked tree untouched. Through v2.9.7 it # rewrote the hand-written ios/RustyNES/Info.plist on every run (an # `info:` block in project.yml made XcodeGen treat the plist as output), # and on a throwaway CI checkout nobody saw it. diff --git a/docs/ios.md b/docs/ios.md index b4ba913e..33c6c734 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -252,15 +252,17 @@ device `.a` (`cargo run -p rustynes-mobile --bin uniffi-bindgen -- generate --library … --language swift`; rename the modulemap to `module.modulemap`) -> assemble the headers dir (`rustynes_mobileFFI.h` + `rustynes_ios.h` + `module.modulemap`) -> `xcodebuild -create-xcframework` -> `xcodegen generate`. -CI (`.github/workflows/ios.yml`) runs this on `macos-latest`, **at release time -only** because macOS minutes bill ~10x — the host `ci.yml` remains the accuracy / -determinism authority and is never gated on a device toolchain. In practice -"release time" means `release-auto.yml`, which calls `ios.yml` (`workflow_call`) -for every release it cuts. Its `push: tags` trigger never fires for those, -because the auto-release tag is pushed with `GITHUB_TOKEN`. So from v2.3.9 to -v2.9.7 no release built the iOS host, until the call was added at v2.9.8. -Manual dispatch remains for an on-demand run, for example on a branch before a -release. Each run then **compiles the Swift app** for the generic iOS Simulator, +CI (`.github/workflows/ios.yml`) runs this on `macos-latest`, **never on a pull +request**, because macOS minutes bill ~10x — the host `ci.yml` remains the +accuracy / determinism authority and is never gated on a device toolchain. It +runs for a release, by hand, and on the TestFlight refresh cron described below +(dormant until the `IOS_SIGNING_READY` repo variable is set). "For a release" +means `release-auto.yml`, which calls `ios.yml` (`workflow_call`) for every +release it cuts. Its `push: tags` trigger never fires for those, because the +auto-release tag is pushed with `GITHUB_TOKEN`. So no release from v2.3.9 to +v2.9.7 built the iOS host. The call is unreleased as of this writing: it was +added after v2.9.7 and first runs on the next release. Manual dispatch remains +for an on-demand run, for example on a release branch before it merges. Each run then **compiles the Swift app** for the generic iOS Simulator, unsigned. It is the only place CI builds the Swift target, and v2.9.7 shipped with 24 Swift compile errors because nothing did. The run also fails if the script modified any tracked file. `fastlane` (`match` read-only signing + `gym` + `pilot`) uploads From c6d2bce7104d9b89a4a97c4c2cbdb1562d6a76f1 Mon Sep 17 00:00:00 2001 From: Luke Parobek Date: Fri, 2 Oct 2026 01:08:38 -0400 Subject: [PATCH 11/11] ci(ios): pass ios.yml only its six secrets; document the touched functions (review) PR #578 review (CodeRabbit, full review of 9c3fbd85: "No actionable comments", Merge Risk minimal). Two items in its summary were acted on. * Least privilege for the called workflow. Its security review noted that the release path can reach the App Store Connect and fastlane match credentials, and that their scope could not be bounded. The part this PR controls was `secrets: inherit` on release-auto's `ios` job, which handed ios.yml every repository secret. ios.yml reads exactly six: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_CONTENT, MATCH_GIT_URL, MATCH_PASSWORD, MATCH_GIT_BASIC_AUTHORIZATION (`grep -o 'secrets\.[A-Z_]*' ios.yml | sort -u`). ios.yml now declares those six under `workflow_call.secrets`, all `required: false`, because their absence is the normal unprovisioned state: the "Detect iOS signing secrets" step then skips the upload. release-auto now passes them by name. Tag pushes, dispatch and the cron read the repo's secrets directly, so they are unaffected. * Docstring coverage (pre-merge check: 63% of the functions this diff touches, threshold 80%). Doc comments were added to the four touched functions that carried logic without one: CloudSaveStateSync.refreshAccount() (when it touches CloudKit, and what "unavailable" means to callers); CloudKitEntitlement's simulatorEntitlements(), which also records why image 0 and not `dladdr` (in Debug the app's code is in RustyNES.debug.dylib, and only the stub executable carries __TEXT,__entitlements); plist(_:); and MetalGameView.Coordinator.step(_:) (deferred build, 60 Hz lock, and the pacing fallback). The remaining undocumented touched function is a one-line local helper (`has(_:)`) inside a drawing closure. Verified: actionlint passes on both workflows, and the generic iOS-Simulator unsigned build reports BUILD SUCCEEDED. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ios.yml | 17 +++++++++++++++++ .github/workflows/release-auto.yml | 15 +++++++++++---- ios/RustyNES/CloudSaveStateSync.swift | 15 +++++++++++++++ ios/RustyNES/MetalGameView.swift | 8 ++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 5d87acc4..8f2afd88 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -36,6 +36,23 @@ on: description: "Release tag to build (e.g. v2.9.8)." required: true type: string + # Exactly the secrets the TestFlight steps read, passed by name from + # release-auto.yml rather than with `secrets: inherit`, which would hand this + # workflow every repository secret. All optional: absent, the "Detect iOS + # signing secrets" step skips the upload, as it does on a tag push. + secrets: + ASC_KEY_ID: + required: false + ASC_ISSUER_ID: + required: false + ASC_KEY_CONTENT: + required: false + MATCH_GIT_URL: + required: false + MATCH_PASSWORD: + required: false + MATCH_GIT_BASIC_AUTHORIZATION: + required: false # v1.9.1 "Patch" — TestFlight build-refresh cadence. A TestFlight build expires # 90 days after upload; re-build + re-upload at 06:00 UTC on the 1st of every # other month (~60-day cadence) so external testers never lose access between diff --git a/.github/workflows/release-auto.yml b/.github/workflows/release-auto.yml index 8bd0a23b..4cb623d4 100644 --- a/.github/workflows/release-auto.yml +++ b/.github/workflows/release-auto.yml @@ -264,7 +264,14 @@ jobs: uses: ./.github/workflows/ios.yml with: tag: ${{ needs.prepare.outputs.tag }} - # The App Store Connect + fastlane match secrets ios.yml reads for the - # TestFlight upload. Its "Detect iOS signing secrets" step skips the upload - # when they are absent. - secrets: inherit + # Only the App Store Connect + fastlane match secrets ios.yml reads for the + # TestFlight upload, by name. `secrets: inherit` would pass every repository + # secret to the called workflow (least privilege, CodeRabbit on #578). Its + # "Detect iOS signing secrets" step skips the upload when they are absent. + secrets: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} diff --git a/ios/RustyNES/CloudSaveStateSync.swift b/ios/RustyNES/CloudSaveStateSync.swift index bedbd79e..7af93d94 100644 --- a/ios/RustyNES/CloudSaveStateSync.swift +++ b/ios/RustyNES/CloudSaveStateSync.swift @@ -130,6 +130,12 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Account availability + /// Re-read whether the user's iCloud account can be used, into `accountAvailable`. + /// + /// Touches CloudKit only when sync is enabled and the binary carries the + /// container entitlement (`Self.container` is nil otherwise). Every other case, + /// a disabled sync, a missing entitlement or a failed status call, leaves the + /// account unavailable, which every caller treats as "stay local". private func refreshAccount() async { guard enabled else { accountAvailable = false @@ -426,6 +432,14 @@ enum CloudKitEntitlement { }() #if targetEnvironment(simulator) + /// The entitlements Xcode linked into the main executable's + /// `__TEXT,__entitlements` section, or nil if there is none (an unsigned + /// build). + /// + /// Image 0 is dyld's main executable, which is deliberate: in a Debug build the + /// app's code lives in `RustyNES.debug.dylib`, and only the stub executable + /// carries the section, so resolving the image from one of our own symbols + /// (`dladdr`) would look in the wrong file. private static func simulatorEntitlements() -> [String: Any]? { guard let header = _dyld_get_image_header(0) else { return nil } var size: UInt = 0 @@ -449,6 +463,7 @@ enum CloudKitEntitlement { } #endif + /// Decode a property list (XML or binary) whose root is a dictionary, or nil. private static func plist(_ data: Data) -> [String: Any]? { (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any] } diff --git a/ios/RustyNES/MetalGameView.swift b/ios/RustyNES/MetalGameView.swift index 57872245..cc913570 100644 --- a/ios/RustyNES/MetalGameView.swift +++ b/ios/RustyNES/MetalGameView.swift @@ -145,6 +145,14 @@ struct MetalGameView: UIViewRepresentable { displayLink = link } + /// One display-link tick: complete a deferred renderer build or apply a + /// drawable resize, then run the console frames this tick owes. + /// + /// The link starts before the renderer exists (see `attachAndStart`), so + /// the first ticks may only build it; `EmulatorCore.tick()` is a no-op + /// until it does. On a ~60 Hz link exactly one console frame runs per + /// vsync; on any other refresh the wall-clock accumulator below paces the + /// core at the console's 60.0988 Hz. @objc private func step(_ link: CADisplayLink) { // If the drawable resized (rotation / Stage Manager), reconfigure first. if let view, view.drawableSize != lastDrawableSize {