Skip to content
Open
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
16 changes: 16 additions & 0 deletions .claude/docs/technology-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@

Open `Code.xcodeproj` in Xcode 16.x. Swift packages resolve automatically on first open. Build and run the `Flipcash` scheme.

## Commit stamp

`Scripts/write_commit_xcconfig` writes `GIT_COMMIT_SHA` into the gitignored
`Configurations/commit.local.xcconfig`; Info.plist exposes it as `FCGitCommit`, read by
`AppMeta.commit` and parsed by `BuildCommit`. It runs from the Flipcash scheme's build
pre-action (pre-actions run before build settings are read, so the value lands in the same
build; a Run Script phase would be one build stale and can't reach `.git` under
`ENABLE_USER_SCRIPT_SANDBOXING`) and from `ci_scripts/ci_pre_xcodebuild.sh`, which prefers
`CI_COMMIT` and never marks the build dirty. Locally, `*` marks uncommitted changes to tracked
files (`git diff --quiet HEAD`, excluding the generated `GoogleService-Info.plist`), the same rule
Android uses. `base.xcconfig` defaults it to `unknown`. The footer format is shared with Android:
keep `AppMeta.versionFooter` and Android's `VersionInfo.commitLabel` in step. The track after
the hash comes from `ReleaseTrack`: `development` under `#if DEBUG`, `beta` when StoreKit's
`AppTransaction` environment is `.sandbox` (TestFlight), none on the App Store. Don't
change `CFBundleVersion` for this: `SessionAuthenticator.requiresUpgrade` parses it.

## Protos: consumed, not generated here

This repo no longer vendors `.proto` files or runs protoc. The generated Swift ships from two
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Private assets
Configurations/secrets.local.xcconfig
Configurations/commit.local.xcconfig
ci_scripts/ci_post_xcodebuild.sh
.env
.env.local
Expand Down
16 changes: 16 additions & 0 deletions Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Stamp Commit"
scriptText = "&quot;$SOURCE_ROOT/Scripts/write_commit_xcconfig&quot; &quot;$SOURCE_ROOT&quot;&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9A516F792D9B12E800AF478B"
BuildableName = "Flipcash.app"
BlueprintName = "Flipcash"
ReferencedContainer = "container:Code.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
Expand Down
6 changes: 6 additions & 0 deletions Configurations/base.xcconfig
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
#include? "secrets.xcconfig"
#include? "secrets.local.xcconfig"

// The commit being built. Scripts/write_commit_xcconfig overrides it from the
// scheme pre-action and ci_scripts/ci_pre_xcodebuild.sh; a clone that never ran
// either reports `unknown`.
GIT_COMMIT_SHA = unknown
#include? "commit.local.xcconfig"
7 changes: 5 additions & 2 deletions Flipcash/Core/Screens/Main/You/YouScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ struct YouScreen: View {
/// The version footer's beta-access easter egg — the tap count and the line
/// it shows for the last few taps.
@State private var versionUnlock = VersionTapUnlock()
@State private var releaseTrack: String?
@State private var isShowingDownloadOptions = false

/// The format tapped in the download sheet, held until the sheet is gone so
Expand Down Expand Up @@ -464,8 +465,9 @@ struct YouScreen: View {
betaFlags.setAccessGranted(!betaFlags.accessGranted)
}
} label: {
Text("Version \(AppMeta.version) • Build \(AppMeta.build)")
.lineLimit(1)
Text(AppMeta.versionFooter(commit: BuildCommit(AppMeta.commit), track: releaseTrack))
.lineLimit(2)
.multilineTextAlignment(.center)
.font(.appTextHeading)
.foregroundStyle(Color.textSecondary)
.padding(.vertical, 12)
Expand All @@ -492,6 +494,7 @@ struct YouScreen: View {
}
}
.animation(.springFaster, value: versionUnlock.message)
.task { releaseTrack = await ReleaseTrack.current() }
}

// MARK: - Content -
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Supporting Files/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
<key>UIColorName</key>
<string>background</string>
</dict>
<key>FCGitCommit</key>
<string>$(GIT_COMMIT_SHA)</string>
<key>bugsnag</key>
<dict>
<key>apiKey</key>
Expand Down
3 changes: 3 additions & 0 deletions Flipcash/Utilities/ErrorReporting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ enum ErrorReporting {
static func initialize() {
let config = BugsnagConfiguration.loadConfig()
config.maxStringValueLength = 50_000
// The raw stamp, `*` dirty marker included: CFBundleVersion is Xcode Cloud's run
// counter and maps to no commit on its own.
config.addMetadata(AppMeta.commit, key: "commit", section: "app")
Bugsnag.start(with: config)
isEnabled = true
}
Expand Down
36 changes: 36 additions & 0 deletions Flipcash/Utilities/ReleaseTrack.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// ReleaseTrack.swift
// Flipcash
//

import StoreKit

/// The distribution channel shown after the commit in the version footer, named as Android names it.
nonisolated enum ReleaseTrack {

/// The track for this build: `development` for a DEBUG build, `beta` on TestFlight, and
/// `nil` on the App Store or when StoreKit can't say.
///
/// TestFlight and the App Store run the same Release binary, so only StoreKit's
/// `AppTransaction` environment tells them apart.
static func current() async -> String? {
#if DEBUG
return name(isDebug: true, environment: nil)
#else
let environment = try? await AppTransaction.shared.payloadValue.environment
return name(isDebug: false, environment: environment)
#endif
}

/// Maps a build to its track name; `nil` means no track is shown.
static func name(isDebug: Bool, environment: AppStore.Environment?) -> String? {
if isDebug {
return "development"
}
// AppStore.Environment is a struct, so this can't be an exhaustive switch.
if environment == .sandbox {
return "beta"
}
return nil
}
}
25 changes: 25 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,29 @@ public enum AppMeta {
public static var build: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? unknown
}

/// The git commit stamp the app was built from, or ``unknown`` when the host bundle doesn't
/// declare one. Parse it with ``BuildCommit`` for display.
public static var commit: String {
Bundle.main.object(forInfoDictionaryKey: "FCGitCommit") as? String ?? unknown
}

/// The You screen's version footer, in the format Android's footer uses.
///
/// Two lines, `Version <version> • Build <build>` over the commit label followed by
/// ` • <track>` when there is one. Without a commit it falls back to the single line
/// `Version <version> • Build <build>`, with the track appended the same way.
public static func versionFooter(
version: String = version,
build: String = build,
commit: BuildCommit,
track: String? = nil
) -> String {
let trackSuffix = track.map { " • \($0)" } ?? ""
let versionLine = "Version \(version) • Build \(build)"
guard let label = commit.label else {
return versionLine + trackSuffix
}
return versionLine + "\n" + label + trackSuffix
}
}
45 changes: 45 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// BuildCommit.swift
// FlipcashCore
//

import Foundation

/// The git commit a build was made from, parsed from the stamp in ``AppMeta/commit``.
///
/// The stamp is a 40-character SHA, suffixed `*` when a local build had uncommitted changes to
/// tracked files. Anything else, including ``AppMeta/unknown``, parses as no commit.
public struct BuildCommit: Equatable, Sendable {

/// Characters of the SHA shown in ``label``, matched by Android's version footer.
public static let labelLength = 10

private static let dirtyMarker = "*"

/// The full 40-character SHA, or `nil` when the build carried no usable stamp.
public let sha: String?

/// Whether the build was made with uncommitted changes to tracked files.
public let isDirty: Bool

/// Parses a stamp as written by `Scripts/write_commit_xcconfig`.
public init(_ stamp: String) {
let dirty = stamp.hasSuffix(Self.dirtyMarker)
let candidate = dirty ? String(stamp.dropLast(Self.dirtyMarker.count)) : stamp

if candidate.count == 40, candidate.allSatisfy(\.isHexDigit) {
sha = candidate.lowercased()
isDirty = dirty
} else {
sha = nil
isDirty = false
}
}

/// The commit as the version footer shows it: the first ``labelLength`` characters of the
/// SHA with `*` appended for a dirty build, or `nil` when there is no commit.
public var label: String? {
guard let sha else { return nil }
return String(sha.prefix(Self.labelLength)) + (isDirty ? Self.dirtyMarker : "")
}
}
5 changes: 5 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/AppMetaTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ struct AppMetaTests {
#expect(!AppMeta.build.isEmpty)
}

@Test("Commit reads without trapping and is never empty")
func commitDegrades() {
#expect(!AppMeta.commit.isEmpty)
}

@Test("The build stand-in stays unparseable, so requiresUpgrade allows access")
func buildStandInIsNotNumeric() {
#expect(UInt32(AppMeta.unknown) == nil)
Expand Down
81 changes: 81 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// BuildCommitTests.swift
// FlipcashCoreTests
//

import Testing
@testable import FlipcashCore

@Suite("BuildCommit — parsing the build's commit stamp and formatting the version footer")
struct BuildCommitTests {

private static let sha = "2acfecffeb4d1c3b8e0f9a7d6c5b4a3928170615"

@Test("A clean stamp keeps the full SHA and labels it with the first 10 characters")
func cleanStamp() {
let commit = BuildCommit(Self.sha)
#expect(commit.sha == Self.sha)
#expect(commit.isDirty == false)
#expect(commit.label == "2acfecffeb")
}

@Test("A dirty stamp keeps the full SHA and appends an asterisk to the label")
func dirtyStamp() {
let commit = BuildCommit(Self.sha + "*")
#expect(commit.sha == Self.sha)
#expect(commit.isDirty)
#expect(commit.label == "2acfecffeb*")
}

@Test("An uppercase SHA is normalized to git's lowercase")
func uppercaseStamp() {
#expect(BuildCommit(Self.sha.uppercased()).sha == Self.sha)
}

@Test(
"Anything that isn't a 40-character SHA has no label",
arguments: [
AppMeta.unknown,
"",
"$(GIT_COMMIT_SHA)",
"*",
"2acfecffeb",
"2acfecffeb*",
"2acfecffeb4d1c3b8e0f9a7d6c5b4a3928170615-dirty",
"2acfecffeb4d1c3b8e0f9a7d6c5b4a39281706150",
"zacfecffeb4d1c3b8e0f9a7d6c5b4a3928170615",
]
)
func unusableStamp(stamp: String) {
let commit = BuildCommit(stamp)
#expect(commit.sha == nil)
#expect(commit.isDirty == false)
#expect(commit.label == nil)
}

// MARK: - Footer -

@Test("The footer puts the commit on its own line")
func footerWithCommit() {
let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(Self.sha))
#expect(footer == "Version 2026.9.3 • Build 272\n2acfecffeb")
}

@Test("A track follows the commit on the second line")
func footerWithCommitAndTrack() {
let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(Self.sha + "*"), track: "beta")
#expect(footer == "Version 2026.9.3 • Build 272\n2acfecffeb* • beta")
}

@Test("Without a commit the footer is the old single line")
func footerWithoutCommit() {
let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(AppMeta.unknown))
#expect(footer == "Version 2026.9.3 • Build 272")
}

@Test("Without a commit a track stays on the single line")
func footerWithoutCommitWithTrack() {
let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(AppMeta.unknown), track: "beta")
#expect(footer == "Version 2026.9.3 • Build 272 • beta")
}
}
27 changes: 27 additions & 0 deletions FlipcashTests/ReleaseTrackTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// ReleaseTrackTests.swift
// FlipcashTests
//

import StoreKit
import Testing
@testable import Flipcash

@Suite("Release track shown in the version footer")
struct ReleaseTrackTests {

@Test("A debug build is development, whatever StoreKit says", arguments: [nil, .sandbox, .production, .xcode] as [AppStore.Environment?])
func debugIsDevelopment(environment: AppStore.Environment?) {
#expect(ReleaseTrack.name(isDebug: true, environment: environment) == "development")
}

@Test("TestFlight is beta")
func testFlightIsBeta() {
#expect(ReleaseTrack.name(isDebug: false, environment: .sandbox) == "beta")
}

@Test("The App Store, an Xcode install, and an unknown environment show no track", arguments: [nil, .production, .xcode] as [AppStore.Environment?])
func otherwiseNoTrack(environment: AppStore.Environment?) {
#expect(ReleaseTrack.name(isDebug: false, environment: environment) == nil)
}
}
Loading
Loading