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