From 44d6445bdff3a977433f8e78278794c9a5f264ee Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:17:42 +0200 Subject: [PATCH] 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 --- bitchat/Localizable.xcstrings | 200 ------------------ bitchat/Nostr/NostrProtocol.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 2 +- bitchat/Services/NostrTransport.swift | 2 +- .../Views/Components/MeshEmptyStateView.swift | 1 + bitchat/Views/ContentHeaderView.swift | 5 +- bitchat/Views/ContentView.swift | 2 +- bitchat/Views/GroupChatList.swift | 15 +- bitchatTests/LocalizationCoverageTests.swift | 65 +++++- .../Services/BLEAnnounceHandlerTests.swift | 28 +-- .../Services/ComposerDraftStoreTests.swift | 32 +-- 11 files changed, 114 insertions(+), 240 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..726440d3 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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", diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 2e3e35d8..ead286d1 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -731,7 +731,7 @@ struct NostrProtocol { } // Perform ECDH - let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( + let sharedSecret = keyAgreementPrivateKey.sharedSecretFromKeyAgreement( with: keyAgreementPublicKey, format: .compressed ) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index edd630e9..a07e5d31 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -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 } diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 8c62d711..10f51219 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -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 } diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift index b225927d..4b4fe26d 100644 --- a/bitchat/Views/Components/MeshEmptyStateView.swift +++ b/bitchat/Views/Components/MeshEmptyStateView.swift @@ -9,6 +9,7 @@ // This is free and unencumbered software released into the public domain. // +import Combine import SwiftUI struct MeshEmptyStateView: View { diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index f857ba38..c29a9a84 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -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) } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 7c07d546..9b09c73d 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -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 diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift index 4dff5c1d..564d656c 100644 --- a/bitchat/Views/GroupChatList.swift +++ b/bitchat/Views/GroupChatList.swift @@ -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, diff --git a/bitchatTests/LocalizationCoverageTests.swift b/bitchatTests/LocalizationCoverageTests.swift index 29bea3b8..918bb8b6 100644 --- a/bitchatTests/LocalizationCoverageTests.swift +++ b/bitchatTests/LocalizationCoverageTests.swift @@ -16,11 +16,15 @@ struct LocalizationCoverageTests { var allLocales: Set { 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] = [:] 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 diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 8e3f533d..fdfdfa40 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -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()) } diff --git a/bitchatTests/Services/ComposerDraftStoreTests.swift b/bitchatTests/Services/ComposerDraftStoreTests.swift index ff071ae5..d4f0a603 100644 --- a/bitchatTests/Services/ComposerDraftStoreTests.swift +++ b/bitchatTests/Services/ComposerDraftStoreTests.swift @@ -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..