diff --git a/apps/swift-ios/Features/Settings/BuildChangelog.swift b/apps/swift-ios/Features/Settings/BuildChangelog.swift new file mode 100644 index 000000000000..b87f78246b3e --- /dev/null +++ b/apps/swift-ios/Features/Settings/BuildChangelog.swift @@ -0,0 +1,215 @@ +import Foundation +import SwiftUI + +struct BuildChangelog: Codable, Equatable, Sendable { + struct Entry: Codable, Equatable, Identifiable, Sendable { + let commit: String + let title: String + let summary: String + let pullRequest: Int? + let pullRequestURL: URL? + + var id: String { commit } + var shortCommit: String { String(commit.prefix(7)) } + var displaySummary: String? { + let value = summary.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, + value.localizedCaseInsensitiveCompare( + title.trimmingCharacters(in: .whitespacesAndNewlines) + ) != .orderedSame else { + return nil + } + return value + } + } + + let revision: String + let baseRevision: String? + let repositoryURL: URL? + let generatedBy: String + let omittedCount: Int + let entries: [Entry] + + static let embedded = load(info: Bundle.main.infoDictionary) + + static func load(info: [String: Any]?) -> BuildChangelog? { + guard let encoded = info?["T3BuildChangelog"] as? String, + !encoded.isEmpty, + !encoded.hasPrefix("$("), + let data = Data(base64Encoded: encoded) + else { return nil } + + return try? JSONDecoder().decode(BuildChangelog.self, from: data) + } +} + +struct BuildChangelogView: View { + let changelog: BuildChangelog? + let versionLabel: String + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + header.padding(.bottom, 24) + + if let changelog, !changelog.entries.isEmpty { + if let latest = changelog.entries.last { + latestChange(latest, repositoryURL: changelog.repositoryURL) + .padding(.bottom, 28) + } + + let earlierEntries = Array(changelog.entries.dropLast().reversed()) + if !earlierEntries.isEmpty { + Text("Earlier in this build") + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .accessibilityAddTraits(.isHeader) + .padding(.bottom, 16) + } + ForEach(Array(earlierEntries.enumerated()), id: \.element.id) { index, entry in + milestone( + entry, + repositoryURL: changelog.repositoryURL, + isLast: index == earlierEntries.count - 1 + ) + } + if changelog.omittedCount > 0 { + Text("\(changelog.omittedCount) older changes omitted") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 20) + } + } else if changelog == nil { + ContentUnavailableView( + "No build changelog", + systemImage: "list.bullet.rectangle", + description: Text("This build did not include an embedded changelog.") + ) + .frame(maxWidth: .infinity) + .padding(.top, 48) + } else { + ContentUnavailableView( + "No changes in this build", + systemImage: "checkmark.circle", + description: Text( + changelog?.baseRevision == nil + ? "This build did not configure a base revision for comparison." + : "No commits were found between this build and its configured base revision." + ) + ) + .frame(maxWidth: .infinity) + .padding(.top, 48) + } + } + .padding(20) + } + .background(T3Colors.background) + .navigationTitle("What’s in this build") + .navigationBarTitleDisplayMode(.inline) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Version \(versionLabel)") + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .accessibilityAddTraits(.isHeader) + if let changelog { + Text("Revision \(String(changelog.revision.prefix(7))) · Summaries by \(changelog.generatedBy)") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func latestChange(_ entry: BuildChangelog.Entry, repositoryURL: URL?) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label("New in this version", systemImage: "sparkles") + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.accent) + Text(entry.title) + .font(T3Typography.threadBody) + .fontWeight(.semibold) + .foregroundStyle(T3Colors.textPrimary) + if let summary = entry.displaySummary { + Text(summary) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + changeLinks(entry, repositoryURL: repositoryURL) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(T3Colors.border, lineWidth: 1) + } + } + + private func milestone( + _ entry: BuildChangelog.Entry, + repositoryURL: URL?, + isLast: Bool + ) -> some View { + HStack(alignment: .top, spacing: 14) { + VStack(spacing: 0) { + Circle() + .fill(T3Colors.accent) + .frame(width: 10, height: 10) + .padding(.top, 5) + if !isLast { + Rectangle() + .fill(T3Colors.border) + .frame(width: 2) + .frame(minHeight: 92) + } + } + + VStack(alignment: .leading, spacing: 6) { + Text(entry.title) + .font(T3Typography.threadBody) + .fontWeight(.semibold) + .foregroundStyle(T3Colors.textPrimary) + if let summary = entry.displaySummary { + Text(summary) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + changeLinks(entry, repositoryURL: repositoryURL) + } + .padding(.bottom, isLast ? 0 : 22) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func changeLinks( + _ entry: BuildChangelog.Entry, + repositoryURL: URL? + ) -> some View { + HStack(spacing: 12) { + if let repositoryURL { + Link(destination: repositoryURL.appending(path: "commit/\(entry.commit)")) { + Label(entry.shortCommit, systemImage: "point.topleft.down.to.point.bottomright.curvepath") + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityLabel("Open commit \(entry.shortCommit) on GitHub") + } else { + Text(entry.shortCommit) + } + if let pullRequest = entry.pullRequest, let pullRequestURL = entry.pullRequestURL { + Link(destination: pullRequestURL) { + Label("PR #\(pullRequest)", systemImage: "arrow.triangle.pull") + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityLabel("Open pull request \(pullRequest) on GitHub") + } + } + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.accent) + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift index 8280a031499e..c8d699fb8f97 100644 --- a/apps/swift-ios/Features/Settings/SettingsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -6,10 +6,15 @@ public struct SettingsView: View { @State private var settings: FeatureSettings @State private var isSaving = false @State private var saveErrorMessage: String? + private let appVersionLabel: String + private let buildChangelog: BuildChangelog? public init(model: FeatureRootModel) { self.model = model _settings = State(initialValue: model.snapshot.settings) + let info = Bundle.main.infoDictionary + appVersionLabel = SettingsAboutMetadata.appVersionLabel(info: info) + buildChangelog = BuildChangelog.embedded } public var body: some View { @@ -168,6 +173,24 @@ public struct SettingsView: View { VStack(spacing: 0) { SettingsValueRow(title: "App", value: appDisplayName) settingsDivider + SettingsValueRow(title: "Version", value: appVersionLabel) + settingsDivider + if buildChangelog != nil { + NavigationLink { + BuildChangelogView( + changelog: buildChangelog, + versionLabel: appVersionLabel + ) + } label: { + SettingsNavigationRow( + title: "Build changelog", + systemImage: "clock.arrow.circlepath", + trailingSystemImage: "chevron.right" + ) + } + .buttonStyle(.plain) + settingsDivider + } SettingsValueRow(title: "Platform", value: "Native SwiftUI") settingsDivider Link(destination: URL(string: "https://github.com/pingdotgg/t3code")!) { @@ -221,6 +244,22 @@ public struct SettingsView: View { } } +enum SettingsAboutMetadata { + static func appVersionLabel(info: [String: Any]?) -> String { + let version = nonemptyValue("CFBundleShortVersionString", info: info) ?? "?" + let build = nonemptyValue("CFBundleVersion", info: info) ?? "?" + return "\(version) (\(build))" + } + + private static func nonemptyValue(_ key: String, info: [String: Any]?) -> String? { + guard let value = info?[key] as? String, + !value.isEmpty, + !value.hasPrefix("$(") + else { return nil } + return value + } +} + private struct SettingsSection: View { let title: String let footer: String? diff --git a/apps/swift-ios/README.md b/apps/swift-ios/README.md index 379ed70ca0c5..a90cda5e12b6 100644 --- a/apps/swift-ios/README.md +++ b/apps/swift-ios/README.md @@ -136,6 +136,10 @@ overrides are `T3_SWIFT_DERIVED_DATA_PATH`, `T3_SWIFT_VERSION`, and `T3_SWIFT_VERIFY_BUNDLE_IDENTIFIERS_ONLY=1` to verify the configuration's host and extension bundle identifiers without a device build. +Debug installations embed an offline changelog for the commits after +`upstream/t3code/rebuild-mobile-app-swift`. Set `T3_SWIFT_CHANGELOG_BASE_REF` to +compare with another build base. + ## Release checklist 1. Set a unique `MARKETING_VERSION` and a higher `CURRENT_PROJECT_VERSION`. diff --git a/apps/swift-ios/Resources/Info.plist b/apps/swift-ios/Resources/Info.plist index ca899785e600..8d8e817d48b2 100644 --- a/apps/swift-ios/Resources/Info.plist +++ b/apps/swift-ios/Resources/Info.plist @@ -34,6 +34,8 @@ + T3BuildChangelog + $(T3_BUILD_CHANGELOG) T3ConnectClerkJWTTemplate $(T3CODE_CLERK_JWT_TEMPLATE) T3ConnectClerkPublishableKey diff --git a/apps/swift-ios/Scripts/generate-build-changelog.swift b/apps/swift-ios/Scripts/generate-build-changelog.swift new file mode 100755 index 000000000000..92bf7f99acbb --- /dev/null +++ b/apps/swift-ios/Scripts/generate-build-changelog.swift @@ -0,0 +1,154 @@ +#!/usr/bin/env swift + +import Foundation + +struct Entry: Codable { + let commit: String + let title: String + let summary: String + let pullRequest: Int? + let pullRequestURL: String? +} + +struct Changelog: Codable { + let revision: String + let baseRevision: String? + let repositoryURL: String? + let generatedBy: String + let omittedCount: Int + let entries: [Entry] +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("[swift-ios-changelog] error: \(message)\n".utf8)) + exit(1) +} + +func git(_ arguments: [String], repository: String, required: Bool = true) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", repository] + arguments + let output = Pipe() + process.standardOutput = output + process.standardError = required ? FileHandle.standardError : FileHandle.nullDevice + do { try process.run() } catch { fail("could not launch git: \(error)") } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + if required { fail("git command failed: \(arguments.joined(separator: " "))") } + return nil + } + return String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) +} + +let arguments = CommandLine.arguments +guard arguments.count == 4 else { + fail("usage: generate-build-changelog.swift REPOSITORY BASE_REF OUTPUT") +} + +let repository = arguments[1] +let baseRef = arguments[2] +let outputURL = URL(fileURLWithPath: arguments[3]) +let revision = git(["rev-parse", "HEAD"], repository: repository, required: false) ?? "unknown" +let baseRevision = git(["rev-parse", baseRef], repository: repository, required: false) +let rawPullRequestRepositoryURL = git( + ["remote", "get-url", "upstream"], repository: repository, required: false +) ?? git(["remote", "get-url", "origin"], repository: repository, required: false) +func normalizedRepositoryURL(_ rawValue: String?) -> String? { + guard var value = rawValue else { return nil } + value = value.replacingOccurrences(of: #"\.git$"#, with: "", options: .regularExpression) + if value.hasPrefix("git@") { + value = "https://" + value.dropFirst("git@".count).replacingOccurrences(of: ":", with: "/") + } else if value.hasPrefix("ssh://git@") { + value = "https://" + value.dropFirst("ssh://git@".count) + } + guard var components = URLComponents(string: value), + components.scheme == "https", + components.host != nil else { + return nil + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string +} +let pullRequestRepositoryURL = normalizedRepositoryURL(rawPullRequestRepositoryURL) +let containingRemoteNames = git([ + "for-each-ref", "--contains", revision, + "--format=%(refname:short)", "refs/remotes", +], repository: repository, required: false)? + .split(separator: "\n") + .compactMap { $0.split(separator: "/", maxSplits: 1).first.map(String.init) } + ?? [] +let commitRemoteName = ["upstream", "contrib", "origin"].first { + containingRemoteNames.contains($0) +} +let repositoryURL = normalizedRepositoryURL(commitRemoteName.flatMap { + git(["remote", "get-url", $0], repository: repository, required: false) +}) +let fieldSeparator = Character("\u{1f}") +let recordSeparator = Character("\u{1e}") +let log: String +if baseRevision == nil || revision == "unknown" { + FileHandle.standardError.write( + Data("[swift-ios-changelog] warning: Git history is unavailable; embedding an empty changelog\n".utf8) + ) + log = "" +} else { + log = git([ + "log", "--reverse", + "--format=%H%x1f%s%x1f%b%x1e", "\(baseRef)..HEAD", + ], repository: repository, required: false) ?? "" +} +let pullRequestPattern = try! NSRegularExpression(pattern: #"\(#(\d+)\)$"#) +var entries = log.split(separator: recordSeparator).compactMap { record -> Entry? in + let fields = record.split(separator: fieldSeparator, omittingEmptySubsequences: false) + guard fields.count >= 3 else { return nil } + let commit = String(fields[0]).trimmingCharacters(in: .whitespacesAndNewlines) + let title = String(fields[1]).trimmingCharacters(in: .whitespacesAndNewlines) + let body = String(fields[2]).trimmingCharacters(in: .whitespacesAndNewlines) + let range = NSRange(title.startIndex.. 49_152, + !entries.isEmpty else { break } + entries.removeFirst() + omittedCount += 1 + } while true + guard data.base64EncodedString().utf8.count <= 49_152 else { + fail("changelog metadata exceeds the 48 KiB encoded build-setting limit") + } + try data.write(to: outputURL, options: .atomic) +} catch { + fail("could not write changelog: \(error)") +} diff --git a/apps/swift-ios/Scripts/install-device.sh b/apps/swift-ios/Scripts/install-device.sh index 091d96365db7..a0c9834ae63d 100755 --- a/apps/swift-ios/Scripts/install-device.sh +++ b/apps/swift-ios/Scripts/install-device.sh @@ -19,8 +19,10 @@ require_cmd() { } require_cmd awk +require_cmd base64 require_cmd mktemp require_cmd plutil +require_cmd tr require_cmd xcodebuild require_cmd xcrun @@ -75,9 +77,23 @@ fi build_settings=( "DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM}" ) +CHANGELOG_FILE="" +DEVICE_JSON="" +cleanup() { + [[ -z "${CHANGELOG_FILE}" ]] || rm -f -- "${CHANGELOG_FILE}" + [[ -z "${DEVICE_JSON}" ]] || rm -f -- "${DEVICE_JSON}" +} +trap cleanup EXIT +if [[ "${CONFIGURATION}" == "Debug" ]]; then + CHANGELOG_FILE="$(mktemp -t t3-swift-changelog.XXXXXX)" + CHANGELOG_BASE_REF="${T3_SWIFT_CHANGELOG_BASE_REF:-upstream/t3code/rebuild-mobile-app-swift}" + xcrun swift "${SCRIPT_DIR}/generate-build-changelog.swift" \ + "${APP_DIR}/../.." "${CHANGELOG_BASE_REF}" "${CHANGELOG_FILE}" + BUILD_CHANGELOG="$(base64 < "${CHANGELOG_FILE}" | tr -d '\n')" + build_settings+=("T3_BUILD_CHANGELOG=${BUILD_CHANGELOG}") +fi DEVICE_JSON="$(mktemp -t t3-swift-devices.XXXXXX)" -trap 'unlink "${DEVICE_JSON}" 2>/dev/null || true' EXIT xcrun devicectl list devices --json-output "${DEVICE_JSON}" --quiet >/dev/null DESTINATION_ID="$( xcrun swift "${SCRIPT_DIR}/resolve-device-udid.swift" "${DEVICE_JSON}" "${DEVICE_ID}" diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index c63745ee727a..cf7eab4f4d5e 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -97,7 +97,9 @@ struct HomeThreadMetadataTests { path: "/work/t3code" ), ], - providers: [FeatureProvider(id: "codex-work", name: "Config name")] + providersByEnvironment: [ + "device": [FeatureProvider(id: "codex-work", name: "Config name")], + ] ) #expect(thread.homeEnvironmentLabel(in: snapshot) == "leftbook") @@ -130,7 +132,9 @@ struct HomeThreadMetadataTests { path: "/work/t3code" ), ], - providers: [FeatureProvider(id: "claude", name: "Claude")] + providersByEnvironment: [ + "device": [FeatureProvider(id: "claude", name: "Claude")], + ] ) #expect(thread.homeEnvironmentLabel(in: snapshot) == "steambox") @@ -162,9 +166,11 @@ struct HomeThreadMetadataTests { ), ], threads: [knownThread, customThread], - providers: [ - FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), - FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + providersByEnvironment: [ + "device": [ + FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), + FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + ], ] ) diff --git a/apps/swift-ios/Tests/FeatureTests/SettingsAboutMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/SettingsAboutMetadataTests.swift new file mode 100644 index 000000000000..3c9884747bcd --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/SettingsAboutMetadataTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Settings about metadata") +struct SettingsAboutMetadataTests { + @Test + func formatsAppVersionAndBuild() { + let info: [String: Any] = [ + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "456", + ] + + #expect(SettingsAboutMetadata.appVersionLabel(info: info) == "1.2.3 (456)") + #expect(SettingsAboutMetadata.appVersionLabel(info: nil) == "? (?)") + } + + @Test + func decodesEmbeddedBuildChangelog() throws { + let json = #"{"revision":"abc123","baseRevision":"def456","repositoryURL":"https://github.com/saphid/t3code","generatedBy":"Git history","omittedCount":2,"entries":[{"commit":"abc123","title":"Fix sync","summary":"Keeps messages in sync.","pullRequest":42,"pullRequestURL":"https://github.com/pingdotgg/t3code/pull/42"}]}"# + let info = ["T3BuildChangelog": Data(json.utf8).base64EncodedString()] + let changelog = try #require(BuildChangelog.load(info: info)) + + #expect(changelog.revision == "abc123") + #expect(changelog.generatedBy == "Git history") + #expect(changelog.omittedCount == 2) + #expect(changelog.entries.first?.pullRequest == 42) + #expect(changelog.entries.first?.pullRequestURL?.absoluteString == "https://github.com/pingdotgg/t3code/pull/42") + #expect(changelog.repositoryURL?.absoluteString == "https://github.com/saphid/t3code") + #expect(changelog.entries.first?.shortCommit == "abc123") + #expect(changelog.entries.first?.displaySummary == "Keeps messages in sync.") + #expect(BuildChangelog.load(info: nil) == nil) + #expect(BuildChangelog.load(info: ["T3BuildChangelog": "not base64"]) == nil) + #expect(BuildChangelog.load(info: ["T3BuildChangelog": "$(T3_BUILD_CHANGELOG)"]) == nil) + #expect(SettingsAboutMetadata.appVersionLabel(info: [ + "CFBundleShortVersionString": "$(MARKETING_VERSION)", + "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", + ]) == "? (?)") + } + + @Test + func changelogSummarySuppressesEmptyAndDuplicateCopy() throws { + let duplicate = BuildChangelog.Entry( + commit: "abc123", + title: "Fix sync", + summary: " fix sync ", + pullRequest: nil, + pullRequestURL: nil + ) + let empty = BuildChangelog.Entry( + commit: "def456", + title: "Add cache", + summary: " ", + pullRequest: nil, + pullRequestURL: nil + ) + + #expect(duplicate.displaySummary == nil) + #expect(empty.displaySummary == nil) + + let json = #"{"revision":"def456","baseRevision":"abc123","repositoryURL":null,"generatedBy":"git","omittedCount":0,"entries":[{"commit":"def456","title":"Add cache","summary":"","pullRequest":null,"pullRequestURL":null}]}"# + let decoded = try #require(BuildChangelog.load(info: [ + "T3BuildChangelog": Data(json.utf8).base64EncodedString(), + ])) + #expect(decoded.entries.first?.displaySummary == nil) + } +}