Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ios/ProgramaSpike/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions ios/ProgramaSpike/ProgramaSpike/AppVersion.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
36 changes: 35 additions & 1 deletion ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -1244,4 +1278,4 @@
}
},
"version": "1.0"
}
}
4 changes: 4 additions & 0 deletions ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ struct WorkspaceListView: View {
}
}
}

Section {
AppVersionRow()
}
}
.navigationTitle(String(localized: "workspaceList.title", defaultValue: "Workspaces"))
.navigationDestination(for: String.self) { workspaceID in
Expand Down
29 changes: 0 additions & 29 deletions ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist

This file was deleted.

29 changes: 27 additions & 2 deletions ios/ProgramaSpike/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
34 changes: 34 additions & 0 deletions scripts/build-ios-testflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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="<absent>"
fi
case "$actual" in *"Could not extract value"*) actual="<absent>" ;; 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
Expand Down
Loading