diff --git a/ios/ProgramaSpike/.gitignore b/ios/ProgramaSpike/.gitignore index d5df7eab..b3086d20 100644 --- a/ios/ProgramaSpike/.gitignore +++ b/ios/ProgramaSpike/.gitignore @@ -6,3 +6,7 @@ ProgramaSpike/ProgramaSpike.entitlements # lives there rather than in INFOPLIST_KEY_* build settings -- see the comment in # project.yml for why mixing the two styles silently drops keys. ProgramaSpike/Info.plist +# The widget's plist is generated the same way and was tracked by accident. A +# committed copy of a generated file drifts from what the build actually +# produces, which is how the hardcoded CFBundleVersion went unnoticed. +ProgramaSpikeWidgets/Info.plist diff --git a/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift b/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift new file mode 100644 index 00000000..87a8a597 --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// The companion's own version and build number, read from the bundle. +/// +/// This exists because there was previously no way, from the phone, to tell +/// which build was installed. The app displayed nothing, every TestFlight build +/// carried the same version string, and the build number is a bare CI run id -- +/// so "is this the current build?" was unanswerable without a Mac and Xcode. +enum AppVersion { + /// `CFBundleShortVersionString`, e.g. "1.0". + static var marketing: String { + bundleString("CFBundleShortVersionString") + } + + /// `CFBundleVersion`. Matches the GitHub Actions run id that produced the + /// build, so it can be compared directly against the run in the repo. + static var build: String { + bundleString("CFBundleVersion") + } + + /// e.g. "1.0 (3057430199401)". + static var displayString: String { + "\(marketing) (\(build))" + } + + private static func bundleString(_ key: String) -> String { + guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String, + !value.isEmpty else { + return String(localized: "about.version.unknown", defaultValue: "unknown") + } + return value + } +} + +/// A single row showing the installed version. Selectable, because the point of +/// it is copying the build number somewhere else to compare. +struct AppVersionRow: View { + var body: some View { + LabeledContent( + String(localized: "about.version.label", defaultValue: "Version"), + value: AppVersion.displayString + ) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } +} diff --git a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift index cf67a67a..a48abaed 100644 --- a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift +++ b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift @@ -105,6 +105,9 @@ struct PairConnectView: View { .foregroundStyle(.red) .font(.footnote) } + // Shown before pairing too: "which build am I on" is most + // often asked when the phone will not connect at all. + AppVersionRow() } Section(String(localized: "notifications.title", defaultValue: "Notifications")) { diff --git a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings b/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings index 59e7cf4f..da0f8e32 100644 --- a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings +++ b/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings @@ -18,6 +18,40 @@ } } }, + "about.version.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン" + } + } + } + }, + "about.version.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, "agentBadge.blocked": { "extractionState": "manual", "localizations": { @@ -1244,4 +1278,4 @@ } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift b/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift index 5c0b8b5a..2da59fd4 100644 --- a/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift +++ b/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift @@ -31,6 +31,10 @@ struct WorkspaceListView: View { } } } + + Section { + AppVersionRow() + } } .navigationTitle(String(localized: "workspaceList.title", defaultValue: "Workspaces")) .navigationDestination(for: String.self) { workspaceID in diff --git a/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist b/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist deleted file mode 100644 index 8b1aab53..00000000 --- a/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist +++ /dev/null @@ -1,29 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Programa Widgets - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/ios/ProgramaSpike/project.yml b/ios/ProgramaSpike/project.yml index 41889bd5..3d466ce7 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -24,7 +24,12 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.darkroom.programa.spike - MARKETING_VERSION: "0.1" + # App Store Connect already holds a build under version 1.0, because the + # plist used to hardcode that literal and this setting never reached the + # bundle. Now that it does, dropping to 0.1 would push the version string + # backwards against a record that already exists, so this stays at 1.0 + # until the companion's version is deliberately aligned with the Mac app. + MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" TARGETED_DEVICE_FAMILY: "1,2" @@ -62,6 +67,16 @@ targets: path: ProgramaSpike/Info.plist properties: CFBundleDisplayName: "Programa" + # Declared explicitly because xcodegen's defaults for these two are the + # literals "1.0" and "1", not references to the build settings. Since + # `info.path` sets INFOPLIST_FILE, Xcode only substitutes $(...) in the + # file -- so the CURRENT_PROJECT_VERSION that + # scripts/build-ios-testflight.sh passes on the xcodebuild command line + # never reached the bundle. Every archive shipped as 1.0 (1) regardless + # of the CI build number, which App Store Connect then rejects on the + # second upload as a duplicate bundle version. + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" UILaunchScreen: {} # Required, not optional: TARGETED_DEVICE_FAMILY is "1,2" so the app # claims iPad support, and App Store validation HARD FAILS an iPad-capable @@ -147,7 +162,12 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.darkroom.programa.spike.widgets - MARKETING_VERSION: "0.1" + # App Store Connect already holds a build under version 1.0, because the + # plist used to hardcode that literal and this setting never reached the + # bundle. Now that it does, dropping to 0.1 would push the version string + # backwards against a record that already exists, so this stays at 1.0 + # until the companion's version is deliberately aligned with the Mac app. + MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" TARGETED_DEVICE_FAMILY: "1,2" @@ -164,5 +184,10 @@ targets: path: ProgramaSpikeWidgets/Info.plist properties: CFBundleDisplayName: Programa Widgets + # Same reason as the app target. These must also track the app exactly: + # App Store validation rejects a bundle whose embedded extension carries + # a different CFBundleVersion than its host app. + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension diff --git a/scripts/build-ios-testflight.sh b/scripts/build-ios-testflight.sh index 45cfa649..b099198a 100755 --- a/scripts/build-ios-testflight.sh +++ b/scripts/build-ios-testflight.sh @@ -255,6 +255,40 @@ verify_failed=0 check_entitlement "aps-environment" "production" || verify_failed=1 check_entitlement "get-task-allow" "false" || verify_failed=1 check_entitlement "com.apple.developer.icloud-container-environment" "Production" || verify_failed=1 +# The build number has to actually reach the bundle, and for a long time it did +# not: xcodegen's default for CFBundleVersion is the literal "1", and because +# `info.path` sets INFOPLIST_FILE, Xcode substitutes only $(...) references -- +# so the CURRENT_PROJECT_VERSION passed on the archive command line was silently +# discarded and every build shipped as 1.0 (1). App Store Connect takes that +# once and rejects everything after it as a duplicate bundle version, which +# reads like a broken upload lane rather than a versioning bug. Check the built +# artifact, never the source. +echo "Verifying bundle version:" +check_bundle_version() { + local label="$1" plist="$2" actual + # plutil writes "Could not extract value" to STDOUT, so a bare fallback would + # report the error text as the value -- same trap as the entitlement checks. + if ! actual="$(plutil -extract CFBundleVersion raw -o - "$plist" 2>/dev/null)"; then + actual="" + fi + case "$actual" in *"Could not extract value"*) actual="" ;; esac + if [[ "$actual" != "$BUILD_NUMBER" ]]; then + echo "FAIL: $label CFBundleVersion is '$actual', expected '$BUILD_NUMBER'" >&2 + return 1 + fi + echo " ok $label CFBundleVersion = $actual" +} +check_bundle_version "app" "$SIGNED_APP/Info.plist" || verify_failed=1 +# An embedded extension whose CFBundleVersion differs from its host app fails +# App Store validation, so the widget is checked against the same number. +WIDGET_PLIST="$SIGNED_APP/PlugIns/ProgramaSpikeWidgets.appex/Info.plist" +if [[ -f "$WIDGET_PLIST" ]]; then + check_bundle_version "widget" "$WIDGET_PLIST" || verify_failed=1 +else + echo "FAIL: widget appex missing from the signed bundle" >&2 + verify_failed=1 +fi + if (( verify_failed )); then echo "Refusing to upload a build that would not behave correctly in TestFlight." >&2 exit 1