Fix swift build on Xcode 27 and clear its Swift 6.4 warnings (#1795)

* Drop letterless string catalog keys so swift build works on Xcode 27
* Test that every manual catalog key can become a Swift symbol
* Silence Xcode 27's new import and capture warnings
* Drop the try markers Xcode 27 flags as unnecessary in tests
* Drop the try on the ECDH call that stopped throwing in #1782
* Tag the group hashtag with the app language and format the header count

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-10-23 22:17:42 +02:00 committed by GitHub
parent e18c073c2c
commit 44d6445bdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 114 additions and 240 deletions

View File

@ -19195,206 +19195,6 @@
}
}
},
"#%@" : {
"comment" : "Non-localizable channel hashtag format used in code",
"extractionState" : "manual",
"shouldTranslate" : false,
"localizations" : {
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "#%@"
}
}
}
},
"%@" : {
"comment" : "Non-localizable symbol used in code",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
}
},
"shouldTranslate" : false
},
"%@ active" : {
"comment" : "A label at the bottom of the people list sheet showing the number of active users.",
"extractionState" : "stale",

View File

@ -731,7 +731,7 @@ struct NostrProtocol {
}
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
let sharedSecret = keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey,
format: .compressed
)

View File

@ -2488,7 +2488,7 @@ final class BLEService: NSObject {
}
// Signature verification and registry lookups run on messageQueue
// like the live receive path.
self.messageQueue.async {
self.messageQueue.async { [self] in
guard self.isCurrentPanicLifecycleGeneration(generation) else {
return
}

View File

@ -184,7 +184,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
private func refreshReachablePeers() {
Task { @MainActor in
Task { @MainActor [self] in
let favorites = dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }

View File

@ -9,6 +9,7 @@
// This is free and unencumbered software released into the public domain.
//
import Combine
import SwiftUI
struct MeshEmptyStateView: View {

View File

@ -277,7 +277,10 @@ struct ContentHeaderView: View {
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular))
Text("\(headerOtherPeersCount)")
// Format style, not Text("\(count)"): same locale digits
// and grouping, and no letterless key for Xcode's
// localization export to add back to the catalog.
Text(headerOtherPeersCount, format: .number)
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: theme.bodyFontDesign))
.accessibilityHidden(true)
}

View File

@ -228,7 +228,7 @@ struct ContentView: View {
var body: some View {
mainContent
.onAppear {
.onAppear { [conversationUIModel, voiceRecordingVM] in
conversationUIModel.setCurrentColorScheme(colorScheme)
conversationUIModel.setCurrentTheme(appTheme)
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in

View File

@ -30,7 +30,7 @@ struct GroupChatList: View {
ForEach(groups) { group in
HStack(spacing: 4) {
Text("#\(group.name)")
hashtagText(for: group)
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
.lineLimit(1)
@ -69,6 +69,19 @@ struct GroupChatList: View {
}
}
/// "#name", not localized: there is nothing to translate, and a
/// letterless "#%@" catalog key breaks string catalog symbol generation
/// (SwiftPM's default build system). It renders as the localized lookup
/// did: the name is isolated (the lookup isolates names containing
/// right-to-left text; isolating every name renders the same), so the
/// "#" stays on the same side of RTL names, and the text is tagged with
/// the app language, as text from a localization table is.
private func hashtagText(for group: GroupChatRow) -> Text {
var hashtag = AttributedString("#\u{2068}\(group.name)\u{2069}")
hashtag.languageIdentifier = Bundle.main.preferredLocalizations.first ?? "en"
return Text(hashtag)
}
private func accessibilityDescription(for group: GroupChatRow) -> String {
var parts: [String] = [
group.name,

View File

@ -16,11 +16,15 @@ struct LocalizationCoverageTests {
var allLocales: Set<String> { coverage.values.reduce(into: []) { $0.formUnion($1) } }
}
private static func loadCatalog(_ relativePath: String) throws -> Catalog {
let url = repoRoot.appendingPathComponent(relativePath)
let data = try Data(contentsOf: url)
/// The catalog's `strings` table: key -> entry.
private static func loadStrings(_ relativePath: String) throws -> [String: Any] {
let data = try Data(contentsOf: repoRoot.appendingPathComponent(relativePath))
let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
let strings = try #require(root["strings"] as? [String: Any])
return try #require(root["strings"] as? [String: Any])
}
private static func loadCatalog(_ relativePath: String) throws -> Catalog {
let strings = try loadStrings(relativePath)
var coverage: [String: Set<String>] = [:]
for (key, value) in strings {
@ -70,6 +74,59 @@ struct LocalizationCoverageTests {
#expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))")
}
/// SwiftPM's default build system on Swift 6.4+ (swiftbuild) generates a
/// Swift symbol for every manually managed catalog key, and fails the
/// whole build on a key it cannot name ("%@", "#%@", "%lld/%lld"). CI may
/// still run the native build system, which never generates symbols, so
/// check the rule here rather than wait for the runner image to move.
/// This covers nameability only: the tool also fails on two manual keys
/// that generate the same symbol ("1" and "_1") and on skipped positional
/// arguments ("%1$@ %3$@"), which this does not check.
@Test func everyManualKeyCanDeriveAGeneratedSymbol() throws {
for path in [Self.mainCatalogPath, Self.shareExtensionCatalogPath] {
let unnameable = try Self.loadStrings(path).compactMap { key, value -> String? in
guard (value as? [String: Any])?["extractionState"] as? String == "manual" else { return nil }
return Self.canDeriveSymbol(from: key) ? nil : key
}.sorted()
#expect(
unnameable.isEmpty,
"""
manual keys in \(path) that string catalog symbol generation \
cannot name, which breaks `swift build`: \(unnameable). Use \
Text(verbatim:) at the call site, or give the key a name.
"""
)
}
}
/// Mirrors `xcstringstool generate-symbols`: printf-style format specifiers
/// split the key into words, and a character (grapheme cluster) can only
/// be part of the symbol name if every scalar in it can be part of a Swift
/// identifier. This approximates that, erring towards rejection, with
/// letters, numbers, marks, `_`, `$`, the joiners ZWJ and ZWNJ, and emoji
/// outside the Basic Multilingual Plane. So 🔒, 👋🏽 and 🧑‍🎄 count, but BMP
/// emoji (✅ ⚡ ❤️) and sequences built on one (🏃‍♂️ ✌🏽) do not.
private static func canDeriveSymbol(from key: String) -> Bool {
let words = key.replacingOccurrences(
of: #"%(\d+\$)?[-+0]*\d*(\.\d+)?(hh|h|ll|l|q|L|z|t|j)?[@dDiuUxXoOfFeEgGcCsSpaA]|%%"#,
with: " ",
options: .regularExpression
)
return words.contains { character in
character.unicodeScalars.allSatisfy { scalar in
switch scalar.properties.generalCategory {
case .uppercaseLetter, .lowercaseLetter, .titlecaseLetter, .modifierLetter, .otherLetter,
.decimalNumber, .letterNumber, .otherNumber,
.nonspacingMark, .spacingMark, .enclosingMark:
return true
default:
return scalar == "_" || scalar == "$" || scalar == "\u{200C}" || scalar == "\u{200D}"
|| (scalar.value >= 0x10000 && scalar.properties.isEmoji)
}
}
}
}
/// The catalog tests above validate the CATALOG; this one validates the
/// CODE. A `String(localized:)` whose key is absent from the catalog its
/// target actually ships compiles and runs fine — it just silently ships

View File

@ -741,14 +741,14 @@ struct BLEAnnounceHandlerTests {
)
let handler = BLEAnnounceHandler(environment: environment)
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) -> BitchatPacket? {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
guard let payload = announcement.encode() else { return nil }
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
@ -758,11 +758,11 @@ struct BLEAnnounceHandlerTests {
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
return signer.signPacket(packet)
}
// Legitimate announce from the victim is accepted and pinned.
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
let victimAnnounce = try #require(makeSignedAnnounce(nickname: "victim", signer: victim))
handler.handle(victimAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
@ -770,7 +770,7 @@ struct BLEAnnounceHandlerTests {
#expect(box.persistedIdentities.count == 1)
// Attacker announce with a valid self-signature must be rejected.
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
let attackerAnnounce = try #require(makeSignedAnnounce(nickname: "attacker", signer: attacker))
handler.handle(attackerAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
@ -778,7 +778,7 @@ struct BLEAnnounceHandlerTests {
#expect(box.persistedIdentities.count == 1)
// The victim's subsequent announces (same pinned key) still work.
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
let victimRename = try #require(makeSignedAnnounce(nickname: "victim-renamed", signer: victim))
handler.handle(victimRename, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
@ -862,14 +862,14 @@ struct BLEAnnounceHandlerTests {
}
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) -> BitchatPacket? {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
guard let payload = announcement.encode() else { return nil }
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
@ -879,7 +879,7 @@ struct BLEAnnounceHandlerTests {
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
return signer.signPacket(packet)
}
func persistedIdentity() -> CryptographicIdentity? {
@ -888,7 +888,7 @@ struct BLEAnnounceHandlerTests {
}
// 1. Victim announces: pinned in the registry and persisted.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
handler.handle(try #require(makeSignedAnnounce(nickname: "victim", signer: victim)), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
@ -898,13 +898,13 @@ struct BLEAnnounceHandlerTests {
// 3. Attacker replay with own signing key: rejected via the persisted
// pin, and neither the registry nor the persisted identity change.
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
handler.handle(try #require(makeSignedAnnounce(nickname: "attacker", signer: attacker)), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
// 4. Victim re-announces with the same signing key: accepted again.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
handler.handle(try #require(makeSignedAnnounce(nickname: "victim", signer: victim)), from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
@ -919,7 +919,7 @@ struct BLEAnnounceHandlerTests {
)
box.registry = BLEPeerRegistry()
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
restartedHandler.handle(try #require(makeSignedAnnounce(nickname: "attacker", signer: attacker)), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
@ -927,7 +927,7 @@ struct BLEAnnounceHandlerTests {
)
// ...while the victim is accepted after the restart.
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
restartedHandler.handle(try #require(makeSignedAnnounce(nickname: "victim", signer: victim)), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
}

View File

@ -6,14 +6,14 @@ import BitFoundation
@Suite(.serialized)
struct ComposerDraftStoreTests {
/// Isolate each test from leftover in-memory drafts.
private func withCleanStore(_ body: () throws -> Void) rethrows {
private func withCleanStore(_ body: () -> Void) {
ComposerDraftStore.replaceAllForTesting([:])
defer { ComposerDraftStore.replaceAllForTesting([:]) }
try body()
body()
}
@Test func emptyDraftIsNotStored() throws {
try withCleanStore {
@Test func emptyDraftIsNotStored() {
withCleanStore {
ComposerDraftStore.save("hello", for: .mesh)
ComposerDraftStore.save(" ", for: .mesh)
// Whitespace-only is still a draft the user typed; empty string clears.
@ -23,8 +23,8 @@ struct ComposerDraftStoreTests {
}
}
@Test func draftsAreIsolatedPerConversation() throws {
try withCleanStore {
@Test func draftsAreIsolatedPerConversation() {
withCleanStore {
let peer = PeerID(str: "aabbccddeeff0011")
ComposerDraftStore.save("mesh draft", for: .mesh)
ComposerDraftStore.save("geo draft", for: .location(geohash: "u4pruy"))
@ -37,8 +37,8 @@ struct ComposerDraftStoreTests {
}
}
@Test func geohashKeysAreCaseInsensitive() throws {
try withCleanStore {
@Test func geohashKeysAreCaseInsensitive() {
withCleanStore {
ComposerDraftStore.save("city chat", for: .location(geohash: "U4PRUY"))
#expect(ComposerDraftStore.load(.location(geohash: "u4pruy")) == "city chat")
}
@ -61,16 +61,16 @@ struct ComposerDraftStoreTests {
#expect(withoutFP == .privateChat(stableID: peer.id))
}
@Test func longDraftsAreTruncated() throws {
try withCleanStore {
@Test func longDraftsAreTruncated() {
withCleanStore {
let long = String(repeating: "a", count: ComposerDraftStore.maxDraftLength + 50)
ComposerDraftStore.save(long, for: .mesh)
#expect(ComposerDraftStore.load(.mesh).count == ComposerDraftStore.maxDraftLength)
}
}
@Test func resetClearsAllDrafts() throws {
try withCleanStore {
@Test func resetClearsAllDrafts() {
withCleanStore {
ComposerDraftStore.save("keep quiet", for: .mesh)
ComposerDraftStore.reset()
#expect(ComposerDraftStore.load(.mesh).isEmpty)
@ -78,8 +78,8 @@ struct ComposerDraftStoreTests {
}
}
@Test func maxDraftCountEvictsOldestKeys() throws {
try withCleanStore {
@Test func maxDraftCountEvictsOldestKeys() {
withCleanStore {
for index in 0..<ComposerDraftStore.maxDraftCount {
ComposerDraftStore.save(
"d\(index)",
@ -92,8 +92,8 @@ struct ComposerDraftStoreTests {
}
}
@Test func clearingDraftRemovesKeySoItDoesNotResurrect() throws {
try withCleanStore {
@Test func clearingDraftRemovesKeySoItDoesNotResurrect() {
withCleanStore {
ComposerDraftStore.save("old text", for: .mesh)
ComposerDraftStore.save("", for: .mesh)
#expect(ComposerDraftStore.load(.mesh).isEmpty)