Context
The current branch (claude/modernize-legacy-repo-9Z7pQ) targets Xcode 14 / Swift 5.7 / iOS 16 because that was the newest Xcode available at time of writing. This issue tracks the remaining steps to reach the fully modern baseline: Xcode 16+ / Swift 6 / iOS 17+.
All changes are mechanical — no architectural work required. The code is already written correctly; this is mostly about unlocking native language features that were held back by the Xcode 14 constraint.
1. Bump Xcode and SDK versions
File: WikiMessage.xcodeproj/project.pbxproj
| Setting |
Current |
Target |
IPHONEOS_DEPLOYMENT_TARGET |
16.0 |
17.0 |
SWIFT_VERSION |
5.7 |
6.0 |
SWIFT_STRICT_CONCURRENCY |
targeted |
complete |
LastUpgradeCheck |
1400 |
1700 (or current) |
LastSwiftUpdateCheck |
1400 |
1700 (or current) |
objectVersion |
56 |
77 (or current Xcode value) |
compatibilityVersion |
"Xcode 14.0" |
"Xcode 14.0" ← keep; this is the minimum Xcode to open the project, not the build Xcode |
Make all of these changes to all four XCBuildConfiguration blocks (Debug/Release for each target) and the two test target configs.
2. Replace ObservableObject with @Observable
@Observable (the Observation framework, iOS 17+) is cleaner than ObservableObject + @Published: no @Published boilerplate, no @StateObject/@EnvironmentObject distinction, and strict concurrency works correctly out of the box.
AppModel.swift
// Before
import Combine
import Messages
final class AppModel: ObservableObject {
@Published var presentationStyle: MSMessagesAppPresentationStyle = .compact
@Published var composer: (any MessageComposer)?
@Published var selectedArticleURL: URL?
}
// After
import Observation
import Messages
@Observable
final class AppModel {
var presentationStyle: MSMessagesAppPresentationStyle = .compact
var composer: (any MessageComposer)?
var selectedArticleURL: URL?
}
SearchModel.swift
// Before
import Combine
final class SearchModel: ObservableObject {
@Published var query: String = ""
@Published private(set) var phase: SearchPhase = .idle
@Published private(set) var recentSearches: [String] = RecentSearchesStore.load()
...
}
// After
import Observation
@Observable
final class SearchModel {
var query: String = ""
private(set) var phase: SearchPhase = .idle
private(set) var recentSearches: [String] = RecentSearchesStore.load()
...
}
NetworkMonitor.swift
// Before
import Combine
import Network
final class NetworkMonitor: ObservableObject, @unchecked Sendable {
@Published private(set) var isConnected: Bool = true
...
}
// After
import Network
import Observation
@Observable
final class NetworkMonitor: @unchecked Sendable {
private(set) var isConnected: Bool = true
...
}
3. Update SwiftUI injection patterns
With @Observable, the environment and state APIs change:
RootView.swift
// Before
@StateObject private var searchModel = SearchModel()
@EnvironmentObject private var appModel: AppModel
// injected via: .environmentObject(searchModel)
// After
@State private var searchModel = SearchModel()
@Environment(AppModel.self) private var appModel
// injected via: .environment(searchModel)
SearchResultsList.swift
// Before
@EnvironmentObject private var searchModel: SearchModel
@EnvironmentObject private var appModel: AppModel
// After
@Environment(SearchModel.self) private var searchModel
@Environment(AppModel.self) private var appModel
MessagesViewController.swift
// Before
let root = RootView().environmentObject(appModel)
// After
let root = RootView().environment(appModel)
4. Restore ContentUnavailableView
ContentUnavailableView is iOS 17+ and was replaced with hand-rolled VStack views. Restore the native API:
LoadingView.swift
var body: some View {
ContentUnavailableView {
ProgressView().controlSize(.large)
} description: {
Text("Searching…").foregroundStyle(.secondary)
}
}
EmptyResultsView.swift
var body: some View {
ContentUnavailableView.search(text: query)
}
ErrorView.swift
var body: some View {
ContentUnavailableView {
Label("Something went wrong", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Try Again", action: retry).buttonStyle(.bordered)
}
}
The idle state in SearchResultsList.swift can similarly be replaced:
case .idle:
ContentUnavailableView(
"Search Wikipedia",
systemImage: "magnifyingglass",
description: Text("Type to search for articles.")
)
5. Switch tests to Swift Testing
Swift Testing (@Suite, @Test, #expect) is built into Xcode 16+ and is the modern replacement for XCTestCase. All four test files need updating.
Pattern (apply to all four files)
// Before (XCTest)
import XCTest
@testable import WikiMessage_MessagesExtension
final class SummaryDTOTests: XCTestCase {
func testDecodesTitle() throws {
let dto = try decoder.decode(SummaryDTO.self, from: fixture())
XCTAssertEqual(dto.title, "Albert_Einstein")
XCTAssertEqual(dto.displayTitle, "Albert Einstein")
}
}
// After (Swift Testing)
import Testing
import Foundation
@testable import WikiMessage_MessagesExtension
@Suite("SummaryDTO decoding")
struct SummaryDTOTests {
@Test func decodesTitle() throws {
let dto = try decoder.decode(SummaryDTO.self, from: fixture())
#expect(dto.title == "Albert_Einstein")
#expect(dto.displayTitle == "Albert Einstein")
}
}
XCTAssertEqual(a, b) → #expect(a == b)
XCTAssertTrue(x) → #expect(x)
XCTAssertNil(x) → #expect(x == nil)
XCTAssertNotNil(x) → #expect(x != nil)
XCTFail("msg") → Issue.record("msg")
XCTAssertThrowsError → await #expect(throws: SomeError.self) { ... }
Also update WikipediaServiceTests to use #expect(throws:) for the error propagation test:
@Test func propagatesHTTPErrors() async throws {
MockURLProtocol.handler = { _ in MockURLProtocol.response(statusCode: 404, data: Data(), url: ...) }
await #expect(throws: HTTPClient.HTTPError.self) {
_ = try await makeService().search(query: "anything")
}
}
6. Update CI
File: .github/workflows/ci.yml
# Before
runs-on: macos-13
xcode-version: "14"
destination: "platform=iOS Simulator,name=iPhone 14,OS=latest"
# After
runs-on: macos-latest
xcode-version: latest-stable
destination: "platform=iOS Simulator,name=iPhone 16,OS=latest"
7. Notes and gotchas
@retroactive on extension URL: @retroactive Identifiable is still valid and required in Swift 6 — no change needed.
SWIFT_STRICT_CONCURRENCY = complete will surface any remaining actor-isolation warnings. Most will be in MessagesViewController (a UIViewController subclass whose lifecycle methods are implicitly @MainActor in Xcode 16 but may need explicit annotation). Expect to add a handful of @MainActor annotations.
actor WikipediaService and actor HTTPClient are already Swift 6 correct — no changes needed there.
MessageComposer protocol conformance (LiveMessageComposer: MessageComposer) may need @MainActor adjustments once strict concurrency is on — check the insert(_:) call site.
- The
@Observable macro requires import Observation, not import Combine. Remove the Combine imports from the model files.
- Fixture JSON loading in tests:
Bundle(for: type(of: self)) is an XCTestCase pattern. With Swift Testing, use Bundle.module if the tests are in a Swift package, or Bundle(for: MockURLProtocol.self) (class reference, works in both).
Acceptance criteria
Context
The current branch (
claude/modernize-legacy-repo-9Z7pQ) targets Xcode 14 / Swift 5.7 / iOS 16 because that was the newest Xcode available at time of writing. This issue tracks the remaining steps to reach the fully modern baseline: Xcode 16+ / Swift 6 / iOS 17+.All changes are mechanical — no architectural work required. The code is already written correctly; this is mostly about unlocking native language features that were held back by the Xcode 14 constraint.
1. Bump Xcode and SDK versions
File:
WikiMessage.xcodeproj/project.pbxprojIPHONEOS_DEPLOYMENT_TARGET16.017.0SWIFT_VERSION5.76.0SWIFT_STRICT_CONCURRENCYtargetedcompleteLastUpgradeCheck14001700(or current)LastSwiftUpdateCheck14001700(or current)objectVersion5677(or current Xcode value)compatibilityVersion"Xcode 14.0""Xcode 14.0"← keep; this is the minimum Xcode to open the project, not the build XcodeMake all of these changes to all four
XCBuildConfigurationblocks (Debug/Release for each target) and the two test target configs.2. Replace
ObservableObjectwith@Observable@Observable(the Observation framework, iOS 17+) is cleaner thanObservableObject + @Published: no@Publishedboilerplate, no@StateObject/@EnvironmentObjectdistinction, and strict concurrency works correctly out of the box.AppModel.swiftSearchModel.swiftNetworkMonitor.swift3. Update SwiftUI injection patterns
With
@Observable, the environment and state APIs change:RootView.swiftSearchResultsList.swiftMessagesViewController.swift4. Restore
ContentUnavailableViewContentUnavailableViewis iOS 17+ and was replaced with hand-rolledVStackviews. Restore the native API:LoadingView.swiftEmptyResultsView.swiftErrorView.swiftThe idle state in
SearchResultsList.swiftcan similarly be replaced:5. Switch tests to Swift Testing
Swift Testing (
@Suite,@Test,#expect) is built into Xcode 16+ and is the modern replacement forXCTestCase. All four test files need updating.Pattern (apply to all four files)
XCTAssertEqual(a, b)→#expect(a == b)XCTAssertTrue(x)→#expect(x)XCTAssertNil(x)→#expect(x == nil)XCTAssertNotNil(x)→#expect(x != nil)XCTFail("msg")→Issue.record("msg")XCTAssertThrowsError→await #expect(throws: SomeError.self) { ... }Also update
WikipediaServiceTeststo use#expect(throws:)for the error propagation test:6. Update CI
File:
.github/workflows/ci.yml7. Notes and gotchas
@retroactiveonextension URL: @retroactive Identifiableis still valid and required in Swift 6 — no change needed.SWIFT_STRICT_CONCURRENCY = completewill surface any remaining actor-isolation warnings. Most will be inMessagesViewController(aUIViewControllersubclass whose lifecycle methods are implicitly@MainActorin Xcode 16 but may need explicit annotation). Expect to add a handful of@MainActorannotations.actor WikipediaServiceandactor HTTPClientare already Swift 6 correct — no changes needed there.MessageComposerprotocol conformance (LiveMessageComposer: MessageComposer) may need@MainActoradjustments once strict concurrency is on — check theinsert(_:)call site.@Observablemacro requiresimport Observation, notimport Combine. Remove theCombineimports from the model files.Bundle(for: type(of: self))is anXCTestCasepattern. With Swift Testing, useBundle.moduleif the tests are in a Swift package, orBundle(for: MockURLProtocol.self)(class reference, works in both).Acceptance criteria
WikiMessageExtensionTeststests pass using Swift Testingmacos-latestwithxcode-version: latest-stabletry!introduced