field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit e7ab5b1c3739a0479cc4a3bb0e2ce00922f0fa80
parent b76add30d75a2cb3235c96d1c76fa6f519bb44d4
Author: triesap <tyson@radroots.org>
Date:   Tue,  1 Sep 2026 02:02:39 +0000

fix(ios): govern user-facing error messages

- map AppleKit and runtime failures onto a closed localized key catalog
- remove raw localized diagnostics and safe-message projection from UI state
- pin the exact Step263 AppleKit contract and regenerate project locks
- cover mappings, redaction canaries, package guards, and bounded config reads

Diffstat:
MPackage.resolved | 4++--
MPackage.swift | 5++++-
MRadroots.xcodeproj/project.pbxproj | 19++++++++++++++++++-
MRadroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4++--
MRadroots/App/RadrootsAppModel.swift | 10+---------
ARadroots/Resources/en.lproj/Localizable.strings | 28++++++++++++++++++++++++++++
MRadroots/Runtime/RadrootsAddMediaCoordinator.swift | 18++++++------------
MRadroots/Runtime/RadrootsLifecycleCoordinator.swift | 8+++-----
MRadroots/Runtime/RadrootsRuntimeModels.swift | 19++-----------------
ARadroots/Runtime/RadrootsUserMessages.swift | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/State/RadrootsAddStore.swift | 17+++++++----------
MRadroots/State/RadrootsConfigurationStore.swift | 7++++++-
MRadroots/State/RadrootsSessionStore.swift | 16++--------------
MRadroots/State/RadrootsSupportingStores.swift | 27++++++++++-----------------
MRadroots/State/RadrootsTodayStore.swift | 11++++++-----
MRadroots/Views/RuntimeStatusView.swift | 18+++++++-----------
MRadrootsTests/RadrootsAddStoreTests.swift | 14+++++++-------
MRadrootsTests/RadrootsStateMigrationTests.swift | 2+-
MRadrootsTests/RadrootsTodayStoreTests.swift | 4++--
ARadrootsTests/RadrootsUserMessagesTests.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 4+++-
Mscripts/verify-package-contract.sh | 23+++++++++++++++++++++++
22 files changed, 605 insertions(+), 118 deletions(-)

diff --git a/Package.resolved b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "2fd3a22c9447cdffbab12bb80ee311003c6a86bbf6a5facc00401faec6447012", + "originHash" : "c2610556ea3533c0a26d74280197bd677881678fe540c3f7e3b09d8c489c687c", "pins" : [ { "identity" : "apple_kit", "kind" : "remoteSourceControl", "location" : "https://github.com/radrootslabs/apple_kit.git", "state" : { - "revision" : "e61386529422a6413a852f0233a196a88f4ded51" + "revision" : "35aedb6b54ff645b663fecff26082b3e91fcb232" } }, { diff --git a/Package.swift b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "radroots_ios_app", + defaultLocalization: "en", platforms: [ .iOS(.v18), ], @@ -13,7 +14,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/radrootslabs/apple_kit.git", - revision: "e61386529422a6413a852f0233a196a88f4ded51" + revision: "35aedb6b54ff645b663fecff26082b3e91fcb232" ), ], targets: [ @@ -56,6 +57,7 @@ let package = Package( "Runtime/RadrootsLifecycleCoordinator.swift", "Runtime/RadrootsRuntimeClient.swift", "Runtime/RadrootsRuntimeModels.swift", + "Runtime/RadrootsUserMessages.swift", "State/RadrootsAddStore.swift", "State/RadrootsConfigurationStore.swift", "State/RadrootsIdentityStore.swift", @@ -70,6 +72,7 @@ let package = Package( ], resources: [ .process("Resources/PrivacyInfo.xcprivacy"), + .process("Resources/en.lproj/Localizable.strings"), ] ), .testTarget( diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -8,10 +8,12 @@ /* Begin PBXBuildFile section */ 0376BB3ECA35B44625702BFE /* RadrootsApp in Frameworks */ = {isa = PBXBuildFile; productRef = E17BA8C6979DF56FB5F385B8 /* RadrootsApp */; }; + 16D54D4DC973AEC37FA4A856 /* RadrootsUserMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F000FE90FD91F0FD50A7C07 /* RadrootsUserMessagesTests.swift */; }; 27B7FB991398E2BE7034C72E /* RadrootsAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */; }; 29A679AE87129E7CBAEA72E9 /* RadrootsRemoteQualificationUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F542C82F0CCB540500C711B /* RadrootsRemoteQualificationUITests.swift */; }; 3BCAFE2B1A96BBE5D741D8FC /* RadrootsSupportingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */; }; 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */; }; + 53C82BC2E86FCB582E8A8B32 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = FD3E502CA8ADE621038AEB47 /* Localizable.strings */; }; 60B853393C8DF4FDF2C01CAF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 81E991EAE0A0EAB4853747C6 /* PrivacyInfo.xcprivacy */; }; 75C044403AF08F7230DE9485 /* RadrootsRootShellUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF484F988B3D1D3D81880388 /* RadrootsRootShellUITests.swift */; }; 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */; }; @@ -47,6 +49,7 @@ /* Begin PBXFileReference section */ 0F542C82F0CCB540500C711B /* RadrootsRemoteQualificationUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRemoteQualificationUITests.swift; sourceTree = "<group>"; }; 298DA81B0A000E307098C840 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; + 2F000FE90FD91F0FD50A7C07 /* RadrootsUserMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsUserMessagesTests.swift; sourceTree = "<group>"; }; 30A8A8321A8A261F6D1B480D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = RadrootsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 397ED29DF8E9BF73B4BBFDEC /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; @@ -66,6 +69,7 @@ D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClientTests.swift; sourceTree = "<group>"; }; D91770F919A06DB39186281B /* RadrootsTodayStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsTodayStoreTests.swift; sourceTree = "<group>"; }; D95CB1F4A964D87D2256DA9C /* RadrootsLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsLifecycleTests.swift; sourceTree = "<group>"; }; + FD3E502CA8ADE621038AEB47 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -101,10 +105,20 @@ C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */, 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */, D91770F919A06DB39186281B /* RadrootsTodayStoreTests.swift */, + 2F000FE90FD91F0FD50A7C07 /* RadrootsUserMessagesTests.swift */, ); path = RadrootsTests; sourceTree = "<group>"; }; + 38960EEDC775B18B1F0F11AE /* en.lproj */ = { + isa = PBXGroup; + children = ( + FD3E502CA8ADE621038AEB47 /* Localizable.strings */, + ); + name = en.lproj; + path = Radroots/Resources/en.lproj; + sourceTree = "<group>"; + }; 401A3C62BEECB8BD5C500756 /* Config */ = { isa = PBXGroup; children = ( @@ -157,6 +171,7 @@ children = ( C22A3ECCB9E9AF9E57B74576 /* App */, 401A3C62BEECB8BD5C500756 /* Config */, + 38960EEDC775B18B1F0F11AE /* en.lproj */, 9458C318B571871852A3FD1B /* Packages */, 31C88176C5674CFF5F9CFBEA /* RadrootsTests */, FD341F0384225E202A0845B1 /* RadrootsUITests */, @@ -292,6 +307,7 @@ buildActionMask = 2147483647; files = ( C356F2A068722087ED91256C /* Assets.xcassets in Resources */, + 53C82BC2E86FCB582E8A8B32 /* Localizable.strings in Resources */, 60B853393C8DF4FDF2C01CAF /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -351,6 +367,7 @@ 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */, 3BCAFE2B1A96BBE5D741D8FC /* RadrootsSupportingStoreTests.swift in Sources */, FE9376121B0780485673EC06 /* RadrootsTodayStoreTests.swift in Sources */, + 16D54D4DC973AEC37FA4A856 /* RadrootsUserMessagesTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -671,7 +688,7 @@ repositoryURL = "https://github.com/radrootslabs/apple_kit.git"; requirement = { kind = revision; - revision = e61386529422a6413a852f0233a196a88f4ded51; + revision = 35aedb6b54ff645b663fecff26082b3e91fcb232; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "184b785843a7de169c88e08e1158041b2087a653433ebd5bc29fd201325bf16e", + "originHash" : "a8f4472559770146a56967d05753c621b410815f7db290b463cd1facc1c391a3", "pins" : [ { "identity" : "apple_kit", "kind" : "remoteSourceControl", "location" : "https://github.com/radrootslabs/apple_kit.git", "state" : { - "revision" : "e61386529422a6413a852f0233a196a88f4ded51" + "revision" : "35aedb6b54ff645b663fecff26082b3e91fcb232" } }, { diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -115,21 +115,13 @@ final class RadrootsAppModel: ObservableObject { addMedia: mediaCoordinator ) bootstrapFailure = nil - } catch let error as LocalizedError { - self.sessionStore = nil - productStores = nil - bootstrapFailure = .local( - operation: "app.bootstrap", - code: "ios.app.configuration_invalid", - safeMessage: error.errorDescription ?? "Radroots configuration is invalid." - ) } catch { self.sessionStore = nil productStores = nil bootstrapFailure = .local( operation: "app.bootstrap", code: "ios.app.configuration_invalid", - safeMessage: "Radroots configuration is invalid." + safeMessage: RadrootsUserMessages.text(for: error, fallback: .configurationInvalid) ) } } diff --git a/Radroots/Resources/en.lproj/Localizable.strings b/Radroots/Resources/en.lproj/Localizable.strings @@ -0,0 +1,28 @@ +"error.add.media_unavailable" = "Photo handling is unavailable."; +"error.add.operation_failed" = "The Add operation could not be completed."; +"error.background_transfer.failed" = "The background transfer did not complete."; +"error.background_transfer.unavailable" = "Background transfer is unavailable."; +"error.configuration.invalid" = "Radroots configuration is invalid."; +"error.diagnostics.export_failed" = "Diagnostics export was cancelled or failed."; +"error.diagnostics.prepare_failed" = "Diagnostics export could not be prepared."; +"error.file.operation_failed" = "The file operation could not be completed."; +"error.identity.operation_failed" = "The local identity operation could not be completed."; +"error.location.unavailable" = "Location is unavailable."; +"error.operation.cancelled" = "The operation was cancelled."; +"error.operation.try_again" = "The operation could not be completed temporarily."; +"error.permission.denied" = "Permission was denied."; +"error.profile.unavailable" = "Your profile is unavailable."; +"error.request.invalid" = "The request is invalid."; +"error.runtime.observation_unavailable" = "Runtime observation is temporarily unavailable."; +"error.runtime.operation_failed" = "The Radroots runtime could not complete the operation."; +"error.runtime.unavailable" = "The Radroots runtime is unavailable."; +"error.search.unavailable" = "Search is unavailable."; +"error.secure_state.unavailable" = "Secure local state is unavailable."; +"error.settings.operation_failed" = "The requested settings operation could not be completed."; +"error.shutdown.failed" = "Radroots could not finish shutting down."; +"error.startup.failed" = "Radroots could not start."; +"error.today.changed" = "Today changed while loading. Refresh to continue."; +"error.today.select_network" = "Choose a local network to load Today."; +"error.today.unavailable" = "Today could not be loaded."; +"status.diagnostics.export_succeeded" = "Diagnostics exported."; +"error.user_presence.unavailable" = "User verification is unavailable."; diff --git a/Radroots/Runtime/RadrootsAddMediaCoordinator.swift b/Radroots/Runtime/RadrootsAddMediaCoordinator.swift @@ -259,7 +259,7 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { value, verification: accepted ? .accepted - : .rejected(code: "background_transfer_rust_verification_failed") + : .rejected(failure: .verificationRejected) ) } catch is CancellationError { throw CancellationError() @@ -428,8 +428,8 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { ) case .failed, .interrupted, .cancelled, .expired: throw Self.failure( - code: snapshot.errorMessage ?? "ios.add.background_upload_failed", - message: "The background photo upload did not complete." + code: snapshot.failure?.rawValue ?? "ios.add.background_upload_failed", + message: RadrootsUserMessages.text(.backgroundTransferFailed) ) case .queued, .running: try await Task.sleep(for: .milliseconds(100)) @@ -539,9 +539,7 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { _ request: RadrootsMediaImportRequest ) async throws -> RadrootsMediaImportResult { guard request.allowedMediaKinds == [.image], request.selectionLimit >= 1 else { - throw RadrootsCaptureIntakeError.invalidRequest( - "remote qualification accepts one image" - ) + throw RadrootsCaptureIntakeError.invalidRequest } try RadrootsAppleFileAccess(roots: roots).write( .inline(RadrootsRemoteQualificationEnvironment.mediaFixtureData()), @@ -556,9 +554,7 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { let size = values.fileSize, (1 ... 40 * 1024 * 1024).contains(size) else { - throw RadrootsCaptureIntakeError.unavailable( - "remote qualification image is unavailable" - ) + throw RadrootsCaptureIntakeError.unavailable } let asset = try RadrootsMediaAsset( source: .libraryImport, @@ -575,9 +571,7 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { func captureMedia( _: RadrootsMediaCaptureRequest ) async throws -> RadrootsMediaCaptureResult { - throw RadrootsCaptureIntakeError.unavailable( - "camera capture is outside remote qualification" - ) + throw RadrootsCaptureIntakeError.unavailable } } #endif diff --git a/Radroots/Runtime/RadrootsLifecycleCoordinator.swift b/Radroots/Runtime/RadrootsLifecycleCoordinator.swift @@ -398,9 +398,7 @@ final class RadrootsDiagnosticsStore: ObservableObject { } catch { activeExport = nil preparedExport = nil - message = - (error as? LocalizedError)?.errorDescription - ?? "Diagnostics export could not be prepared." + message = RadrootsUserMessages.text(.diagnosticsPrepareFailed) } } @@ -410,9 +408,9 @@ final class RadrootsDiagnosticsStore: ObservableObject { preparedExport = nil switch result { case .success: - message = "Diagnostics exported." + message = RadrootsUserMessages.text(.diagnosticsExportSucceeded) case .failure: - message = "Diagnostics export was cancelled or failed." + message = RadrootsUserMessages.text(.diagnosticsExportFailed) } guard let export else { return } Task { await coordinator.releaseDiagnostics(export) } diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift @@ -441,7 +441,7 @@ struct RadrootsRuntimeFailure: Error, Sendable, Equatable { extension RadrootsRuntimeFailure: LocalizedError { var errorDescription: String? { - safeMessage + RadrootsUserMessages.text(.runtimeOperationFailed) } } @@ -460,22 +460,7 @@ enum RadrootsRuntimeClientError: Error, Sendable, Equatable { extension RadrootsRuntimeClientError: LocalizedError { var errorDescription: String? { - switch self { - case .invalidBufferCapacity: - "The requested runtime event buffer is outside the supported range." - case .notRunning: - "The Radroots runtime is not running." - case .superseded: - "A newer runtime lifecycle request replaced this request." - case let .startup(failure), - let .subscription(failure), - let .status(failure), - let .today(failure), - let .add(failure), - let .support(failure), - let .shutdown(failure): - failure.safeMessage - } + RadrootsUserMessages.text(for: self, fallback: .runtimeOperationFailed) } } diff --git a/Radroots/Runtime/RadrootsUserMessages.swift b/Radroots/Runtime/RadrootsUserMessages.swift @@ -0,0 +1,346 @@ +import Foundation +import RadrootsKit + +enum RadrootsUserMessageKey: String, CaseIterable, Sendable { + case addMediaUnavailable = "error.add.media_unavailable" + case addOperationFailed = "error.add.operation_failed" + case backgroundTransferFailed = "error.background_transfer.failed" + case backgroundTransferUnavailable = "error.background_transfer.unavailable" + case configurationInvalid = "error.configuration.invalid" + case diagnosticsExportFailed = "error.diagnostics.export_failed" + case diagnosticsExportSucceeded = "status.diagnostics.export_succeeded" + case diagnosticsPrepareFailed = "error.diagnostics.prepare_failed" + case fileOperationFailed = "error.file.operation_failed" + case identityOperationFailed = "error.identity.operation_failed" + case invalidRequest = "error.request.invalid" + case locationUnavailable = "error.location.unavailable" + case operationCancelled = "error.operation.cancelled" + case permissionDenied = "error.permission.denied" + case profileUnavailable = "error.profile.unavailable" + case runtimeObservationUnavailable = "error.runtime.observation_unavailable" + case runtimeOperationFailed = "error.runtime.operation_failed" + case runtimeUnavailable = "error.runtime.unavailable" + case searchUnavailable = "error.search.unavailable" + case secureStateUnavailable = "error.secure_state.unavailable" + case settingsOperationFailed = "error.settings.operation_failed" + case shutdownFailed = "error.shutdown.failed" + case startupFailed = "error.startup.failed" + case todayChanged = "error.today.changed" + case todaySelectNetwork = "error.today.select_network" + case todayUnavailable = "error.today.unavailable" + case tryAgain = "error.operation.try_again" + case userPresenceUnavailable = "error.user_presence.unavailable" + + var defaultValue: String { + switch self { + case .addMediaUnavailable: + "Photo handling is unavailable." + case .addOperationFailed: + "The Add operation could not be completed." + case .backgroundTransferFailed: + "The background transfer did not complete." + case .backgroundTransferUnavailable: + "Background transfer is unavailable." + case .configurationInvalid: + "Radroots configuration is invalid." + case .diagnosticsExportFailed: + "Diagnostics export was cancelled or failed." + case .diagnosticsExportSucceeded: + "Diagnostics exported." + case .diagnosticsPrepareFailed: + "Diagnostics export could not be prepared." + case .fileOperationFailed: + "The file operation could not be completed." + case .identityOperationFailed: + "The local identity operation could not be completed." + case .invalidRequest: + "The request is invalid." + case .locationUnavailable: + "Location is unavailable." + case .operationCancelled: + "The operation was cancelled." + case .permissionDenied: + "Permission was denied." + case .profileUnavailable: + "Your profile is unavailable." + case .runtimeObservationUnavailable: + "Runtime observation is temporarily unavailable." + case .runtimeOperationFailed: + "The Radroots runtime could not complete the operation." + case .runtimeUnavailable: + "The Radroots runtime is unavailable." + case .searchUnavailable: + "Search is unavailable." + case .secureStateUnavailable: + "Secure local state is unavailable." + case .settingsOperationFailed: + "The requested settings operation could not be completed." + case .shutdownFailed: + "Radroots could not finish shutting down." + case .startupFailed: + "Radroots could not start." + case .todayChanged: + "Today changed while loading. Refresh to continue." + case .todaySelectNetwork: + "Choose a local network to load Today." + case .todayUnavailable: + "Today could not be loaded." + case .tryAgain: + "The operation could not be completed temporarily." + case .userPresenceUnavailable: + "User verification is unavailable." + } + } +} + +enum RadrootsUserMessages { + static func text(_ key: RadrootsUserMessageKey, bundle: Bundle? = nil) -> String { + let bundle = bundle ?? defaultBundle + return bundle.localizedString(forKey: key.rawValue, value: key.defaultValue, table: nil) + } + + static func text( + for error: Error, + fallback: RadrootsUserMessageKey, + bundle: Bundle? = nil + ) -> String { + text(key(for: error, fallback: fallback), bundle: bundle) + } + + static func key( + for error: Error, + fallback: RadrootsUserMessageKey + ) -> RadrootsUserMessageKey { + if error is CancellationError { + return .operationCancelled + } + if let error = error as? RadrootsRuntimeClientError { + return runtimeClientKey(error, fallback: fallback) + } + if error is RadrootsRuntimeFailure { + return fallback + } + if error is RadrootsConfigurationError { + return .configurationInvalid + } + if error is RadrootsIdentityStoreError { + return .identityOperationFailed + } + if let error = error as? RadrootsCaptureIntakeError { + return captureKey(error) + } + if let error = error as? RadrootsBackgroundTransferError { + return backgroundTransferKey(error) + } + if let error = error as? RadrootsDocumentInterchangeError { + return documentKey(error) + } + if let error = error as? RadrootsAppLocalStateResetError { + return localStateResetKey(error) + } + if let error = error as? RadrootsAppleMediaPreparationError { + return mediaPreparationKey(error) + } + if let error = error as? RadrootsTelemetryError { + return telemetryKey(error) + } + if let error = error as? RadrootsExternalActionError { + return externalActionKey(error) + } + if let error = error as? RadrootsAppleFileError { + return fileKey(error) + } + if let error = error as? RadrootsLocationServicesError { + return locationKey(error) + } + if let error = error as? RadrootsUserPresenceError { + return userPresenceKey(error) + } + if let error = error as? RadrootsBackgroundTaskError { + return backgroundTaskKey(error) + } + if let error = error as? RadrootsAppleSecurityError { + return securityKey(error) + } + if let error = error as? RadrootsAppleMobileStoreError { + return mobileStoreKey(error) + } + if let error = error as? RadrootsVerifiedArtifactAccessError { + return verifiedArtifactKey(error) + } + if error is RadrootsIdentityCustodyError { + return .secureStateUnavailable + } + return fallback + } + + private static func runtimeClientKey( + _ error: RadrootsRuntimeClientError, + fallback: RadrootsUserMessageKey + ) -> RadrootsUserMessageKey { + switch error { + case .invalidBufferCapacity: + .invalidRequest + case .notRunning: + .runtimeUnavailable + case .superseded: + .operationCancelled + case .startup: + .startupFailed + case .subscription: + .runtimeObservationUnavailable + case .shutdown: + .shutdownFailed + case .status, .today, .add, .support: + fallback + } + } + + private static func captureKey(_ error: RadrootsCaptureIntakeError) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .unavailable: .addMediaUnavailable + case .permissionDenied: .permissionDenied + case .userCancelled: .operationCancelled + case .transientFailure: .tryAgain + case .permanentFailure: .addMediaUnavailable + } + } + + private static func backgroundTransferKey( + _ error: RadrootsBackgroundTransferError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .unavailable: .backgroundTransferUnavailable + case .transferFailure: .backgroundTransferFailed + case .persistenceFailure: .secureStateUnavailable + } + } + + private static func documentKey( + _ error: RadrootsDocumentInterchangeError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .notFound: .fileOperationFailed + case .userCancelled: .operationCancelled + case .permissionDenied: .permissionDenied + case .transientFailure: .tryAgain + case .permanentFailure: .fileOperationFailed + } + } + + private static func localStateResetKey( + _ error: RadrootsAppLocalStateResetError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .fileSystemFailure, .keychainFailure: .secureStateUnavailable + } + } + + private static func mediaPreparationKey( + _ error: RadrootsAppleMediaPreparationError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .unavailable: .addMediaUnavailable + case .preparationFailure: .addMediaUnavailable + } + } + + private static func telemetryKey(_ error: RadrootsTelemetryError) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + } + } + + private static func externalActionKey( + _ error: RadrootsExternalActionError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .blockedByPolicy, .unavailable: .permissionDenied + case .transientFailure: .tryAgain + case .permanentFailure: .runtimeOperationFailed + } + } + + private static func fileKey(_ error: RadrootsAppleFileError) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .notFound, .permanentFailure: .fileOperationFailed + case .permissionDenied: .permissionDenied + case .transientFailure: .tryAgain + } + } + + private static func locationKey(_ error: RadrootsLocationServicesError) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .permissionDenied: .permissionDenied + case .unavailable, .timeout, .cancelled, .transientFailure, .permanentFailure: + .locationUnavailable + } + } + + private static func userPresenceKey( + _ error: RadrootsUserPresenceError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .userCancelled: .operationCancelled + case .permissionDenied: .permissionDenied + case .unavailable, .timeout, .transientFailure, .permanentFailure: + .userPresenceUnavailable + } + } + + private static func backgroundTaskKey( + _ error: RadrootsBackgroundTaskError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .unavailable, .schedulerFailure: .runtimeUnavailable + } + } + + private static func securityKey(_ error: RadrootsAppleSecurityError) -> RadrootsUserMessageKey { + switch error { + case .invalidRequest: .invalidRequest + case .permissionDenied: .permissionDenied + case .userCancelled: .operationCancelled + case .notFound, .transientFailure, .unavailable, .permanentFailure, .keychainFailure: + .secureStateUnavailable + } + } + + private static func mobileStoreKey( + _ error: RadrootsAppleMobileStoreError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidPublicKey: .invalidRequest + case .protectedDataUnavailable, .invalidDirectoryLayout, .fileSystemFailure: + .secureStateUnavailable + } + } + + private static func verifiedArtifactKey( + _ error: RadrootsVerifiedArtifactAccessError + ) -> RadrootsUserMessageKey { + switch error { + case .invalidDescriptor: .invalidRequest + case .protectedDataUnavailable: .secureStateUnavailable + case .artifactUnavailable, .artifactCorrupt, .fileSystemFailure: .fileOperationFailed + } + } + + private static var defaultBundle: Bundle { + #if SWIFT_PACKAGE + Bundle.module + #else + Bundle.main + #endif + } +} diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -649,7 +649,7 @@ final class RadrootsAddStore: ObservableObject { var attempt: UInt32 = 0 while isStarted, observationGeneration == generation, !Task.isCancelled { observationState = .subscribing(attempt: attempt &+ 1) - var failureMessage = "Runtime observation ended unexpectedly." + var failureMessage = RadrootsUserMessages.text(.runtimeObservationUnavailable) do { let changes = try await runtimeClient.changes(bufferCapacity: 16) observationState = .active @@ -664,9 +664,10 @@ final class RadrootsAddStore: ObservableObject { } } } catch { - failureMessage = - (error as? LocalizedError)?.errorDescription - ?? "Runtime observation is temporarily unavailable." + failureMessage = RadrootsUserMessages.text( + for: error, + fallback: .runtimeObservationUnavailable + ) } guard isStarted, observationGeneration == generation, !Task.isCancelled else { break } attempt = attempt == .max ? .max : attempt + 1 @@ -732,7 +733,7 @@ final class RadrootsAddStore: ObservableObject { try await operation() } catch is CancellationError { if requestedGeneration == generation { - message = "The operation was cancelled." + message = RadrootsUserMessages.text(.operationCancelled) } } catch { if requestedGeneration == generation { @@ -802,11 +803,7 @@ final class RadrootsAddStore: ObservableObject { }() private static func message(for error: Error) -> String { - if let failure = failure(for: error) { - return failure.safeMessage - } - return (error as? LocalizedError)?.errorDescription - ?? "The Add operation could not be completed." + RadrootsUserMessages.text(for: error, fallback: .addOperationFailed) } private static func failure(for error: Error) -> RadrootsRuntimeFailure? { diff --git a/Radroots/State/RadrootsConfigurationStore.swift b/Radroots/State/RadrootsConfigurationStore.swift @@ -76,6 +76,8 @@ extension RadrootsConfigurationError: LocalizedError { } actor RadrootsConfigurationStore { + static let maximumStoredConfigurationBytes = 64 * 1024 + private struct StoredConfigurationV3: Codable, Equatable { static let format = "radroots_ios_configuration_v3" @@ -385,7 +387,10 @@ actor RadrootsConfigurationStore { private func read(_ file: RadrootsFileReference) throws -> Data? { do { - guard case let .inline(data) = try fileAccess.read(file, mode: .inline) else { + guard case let .inline(data) = try fileAccess.read( + file, + mode: .inline(maxBytes: Self.maximumStoredConfigurationBytes) + ) else { throw RadrootsConfigurationError.persistenceFailed } return data diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -224,22 +224,13 @@ actor RadrootsSessionStore { } catch let RadrootsRuntimeClientError.startup(failure) { guard generation == requestedGeneration else { return phase } phase = .failed(failure) - } catch let error as LocalizedError { - guard generation == requestedGeneration else { return phase } - phase = .failed( - .local( - operation: "session.start", - code: "ios.session.start_failed", - safeMessage: error.errorDescription ?? "Radroots could not start." - ) - ) } catch { guard generation == requestedGeneration else { return phase } phase = .failed( .local( operation: "session.start", code: "ios.session.start_failed", - safeMessage: "Radroots could not start." + safeMessage: RadrootsUserMessages.text(for: error, fallback: .startupFailed) ) ) } @@ -451,14 +442,11 @@ actor RadrootsSessionStore { private func failIdentityOperation(_ error: Error) -> RadrootsSessionPhase { generation &+= 1 - let message = - (error as? LocalizedError)?.errorDescription - ?? "The local identity operation could not be completed." phase = .failed( .local( operation: "identity.operation", code: "ios.identity.operation_failed", - safeMessage: message + safeMessage: RadrootsUserMessages.text(for: error, fallback: .identityOperationFailed) ) ) return phase diff --git a/Radroots/State/RadrootsSupportingStores.swift b/Radroots/State/RadrootsSupportingStores.swift @@ -91,10 +91,7 @@ final class RadrootsSearchStore: ObservableObject { } private static func message(for error: Error) -> String { - if case let RadrootsRuntimeClientError.support(failure) = error { - return failure.safeMessage - } - return (error as? LocalizedError)?.errorDescription ?? "Search is unavailable." + RadrootsUserMessages.text(for: error, fallback: .searchUnavailable) } } @@ -186,7 +183,7 @@ final class RadrootsMeStore: ObservableObject { var attempt: UInt32 = 0 while isStarted, observationGeneration == generation, !Task.isCancelled { observationState = .subscribing(attempt: attempt &+ 1) - var failureMessage = "Runtime observation ended unexpectedly." + var failureMessage = RadrootsUserMessages.text(.runtimeObservationUnavailable) do { let changes = try await runtimeClient.changes(bufferCapacity: 8) observationState = .active @@ -201,9 +198,10 @@ final class RadrootsMeStore: ObservableObject { } } } catch { - failureMessage = - (error as? LocalizedError)?.errorDescription - ?? "Runtime observation is temporarily unavailable." + failureMessage = RadrootsUserMessages.text( + for: error, + fallback: .runtimeObservationUnavailable + ) } guard isStarted, observationGeneration == generation, !Task.isCancelled else { break } attempt = attempt == .max ? .max : attempt + 1 @@ -228,10 +226,7 @@ final class RadrootsMeStore: ObservableObject { } private static func message(for error: Error) -> String { - if case let RadrootsRuntimeClientError.support(failure) = error { - return failure.safeMessage - } - return (error as? LocalizedError)?.errorDescription ?? "Your profile is unavailable." + RadrootsUserMessages.text(for: error, fallback: .profileUnavailable) } } @@ -443,12 +438,10 @@ final class RadrootsSettingsStore: ObservableObject { private func record(_ error: Error) { if case let RadrootsRuntimeClientError.support(failure) = error { failureCode = failure.code - message = failure.safeMessage - return + } else { + failureCode = "ios.settings.operation_failed" } - failureCode = "ios.settings.operation_failed" - message = (error as? LocalizedError)?.errorDescription - ?? "The requested settings operation could not be completed." + message = RadrootsUserMessages.text(for: error, fallback: .settingsOperationFailed) } private func optional(_ value: String) -> String? { diff --git a/Radroots/State/RadrootsTodayStore.swift b/Radroots/State/RadrootsTodayStore.swift @@ -227,7 +227,7 @@ final class RadrootsTodayStore: ObservableObject { var attempt: UInt32 = 0 while isStarted, observationGeneration == generation, !Task.isCancelled { observationState = .subscribing(attempt: attempt &+ 1) - var failureMessage = "Runtime observation ended unexpectedly." + var failureMessage = RadrootsUserMessages.text(.runtimeObservationUnavailable) do { let changes = try await runtimeClient.changes(bufferCapacity: 16) observationState = .active @@ -242,9 +242,10 @@ final class RadrootsTodayStore: ObservableObject { } } } catch { - failureMessage = - (error as? LocalizedError)?.errorDescription - ?? "Runtime observation is temporarily unavailable." + failureMessage = RadrootsUserMessages.text( + for: error, + fallback: .runtimeObservationUnavailable + ) } guard isStarted, observationGeneration == generation, !Task.isCancelled else { break } attempt = attempt == .max ? .max : attempt + 1 @@ -282,7 +283,7 @@ final class RadrootsTodayStore: ObservableObject { } else { error as? RadrootsRuntimeFailure } - let message = failure?.safeMessage ?? "Today could not be loaded." + let message = RadrootsUserMessages.text(for: error, fallback: .todayUnavailable) guard let failure else { return .failed(message: message) } let category = failure.category.lowercased() if failure.retryable || category.contains("network") || category.contains("relay") { diff --git a/Radroots/Views/RuntimeStatusView.swift b/Radroots/Views/RuntimeStatusView.swift @@ -136,20 +136,16 @@ struct RuntimeStatusView: View { "Your local Nostr secret remains protected until you explicitly unlock it." case .protectedDataUnavailable: "Protected local data is unavailable while this device is locked." - case let .recoveryRequired(identity): - identity.recoveryCode ?? "A previous identity operation needs recovery." - case let .corruptIdentity(identity): - identity.recoveryCode ?? "Stored identity state is corrupt; it was not treated as absent." - case let .configurationReconfigurationRequired(requirement): - if let fingerprint = requirement.previousBlossomConfigFingerprint { - "Review and apply the new network configuration. Existing uploads remain bound to configuration \(fingerprint.prefix(12))…." - } else { - "Review and apply the new network configuration. Existing local identity and drafts are preserved." - } + case .recoveryRequired: + RadrootsUserMessages.text(.secureStateUnavailable) + case .corruptIdentity: + RadrootsUserMessages.text(.secureStateUnavailable) + case .configurationReconfigurationRequired: + "Review and apply the new network configuration. Existing local identity and drafts are preserved." case let .running(snapshot): "Runtime \(snapshot.crateVersion) is connected to your local data." case let .failed(failure): - failure.safeMessage + RadrootsUserMessages.text(for: failure, fallback: .runtimeOperationFailed) case .stopped: "Your durable local work is safe." } diff --git a/RadrootsTests/RadrootsAddStoreTests.swift b/RadrootsTests/RadrootsAddStoreTests.swift @@ -309,7 +309,7 @@ final class RadrootsAddStoreTests: XCTestCase { await invalidStore.start() XCTAssertEqual( invalidStore.state, - .failed("The Add product contract does not match this app.") + .failed(RadrootsUserMessages.text(.addOperationFailed)) ) XCTAssertFalse(invalidStore.isProductReady) XCTAssertFalse(invalidStore.canSave) @@ -624,7 +624,7 @@ final class RadrootsAddStoreTests: XCTestCase { await store.submit() - XCTAssertEqual(store.message, failure.safeMessage) + XCTAssertEqual(store.message, RadrootsUserMessages.text(.addOperationFailed)) XCTAssertEqual(store.lastFailureCode, failure.code) XCTAssertNil(store.activeDraft) _ = try await client.stop() @@ -1599,11 +1599,11 @@ private struct BackgroundMediaPicker: RadrootsMediaPicker { } func importMedia(_: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult { - throw RadrootsCaptureIntakeError.unavailable("not used") + throw RadrootsCaptureIntakeError.unavailable } func captureMedia(_: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult { - throw RadrootsCaptureIntakeError.unavailable("not used") + throw RadrootsCaptureIntakeError.unavailable } } @@ -1698,17 +1698,17 @@ private actor BackgroundTransferHarness: RadrootsBackgroundTransfer { verification: RadrootsBackgroundTransferVerification ) async throws { guard let value = values[identifier] else { - throw RadrootsBackgroundTransferError.transferFailure("missing snapshot") + throw RadrootsBackgroundTransferError.transferFailure } switch verification { case .accepted: acceptedSettlementCount += 1 values[identifier] = try snapshot(request: value.request, state: .completed) - case let .rejected(code): + case let .rejected(failure): values[identifier] = try RadrootsBackgroundTransferSnapshot( request: value.request, state: .failed, - errorMessage: code + failure: failure ) } } diff --git a/RadrootsTests/RadrootsStateMigrationTests.swift b/RadrootsTests/RadrootsStateMigrationTests.swift @@ -388,7 +388,7 @@ final class RadrootsStateMigrationTests: XCTestCase { scope: .data, relativePath: "settings/radroots_configuration_v3.json" ), - mode: .inline + mode: .inline(maxBytes: RadrootsConfigurationStore.maximumStoredConfigurationBytes) ) guard case let .inline(data) = source, let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] diff --git a/RadrootsTests/RadrootsTodayStoreTests.swift b/RadrootsTests/RadrootsTodayStoreTests.swift @@ -173,10 +173,10 @@ final class RadrootsTodayStoreTests: XCTestCase { await store.reload() XCTAssertEqual(store.cards.map(\.id), ["cached"]) - XCTAssertEqual(store.state, .offline(message: failure.safeMessage)) + XCTAssertEqual(store.state, .offline(message: RadrootsUserMessages.text(.todayUnavailable))) await store.loadNextPage() XCTAssertEqual(store.cards.map(\.id), ["cached", "cached-next"]) - XCTAssertEqual(store.state, .offline(message: failure.safeMessage)) + XCTAssertEqual(store.state, .offline(message: RadrootsUserMessages.text(.todayUnavailable))) _ = try await client.stop() } diff --git a/RadrootsTests/RadrootsUserMessagesTests.swift b/RadrootsTests/RadrootsUserMessagesTests.swift @@ -0,0 +1,119 @@ +import Foundation +@testable import RadrootsApp +import RadrootsKit +import XCTest + +final class RadrootsUserMessagesTests: XCTestCase { + func testMessageKeyInventoryIsClosedAndLocalized() { + XCTAssertEqual( + Set(RadrootsUserMessageKey.allCases.map(\.rawValue)), + [ + "error.add.media_unavailable", + "error.add.operation_failed", + "error.background_transfer.failed", + "error.background_transfer.unavailable", + "error.configuration.invalid", + "error.diagnostics.export_failed", + "error.diagnostics.prepare_failed", + "error.file.operation_failed", + "error.identity.operation_failed", + "error.location.unavailable", + "error.operation.cancelled", + "error.operation.try_again", + "error.permission.denied", + "error.profile.unavailable", + "error.request.invalid", + "error.runtime.observation_unavailable", + "error.runtime.operation_failed", + "error.runtime.unavailable", + "error.search.unavailable", + "error.secure_state.unavailable", + "error.settings.operation_failed", + "error.shutdown.failed", + "error.startup.failed", + "error.today.changed", + "error.today.select_network", + "error.today.unavailable", + "error.user_presence.unavailable", + "status.diagnostics.export_succeeded", + ] + ) + for key in RadrootsUserMessageKey.allCases { + XCTAssertFalse(RadrootsUserMessages.text(key).isEmpty, key.rawValue) + } + } + + func testAppleErrorsMapToClosedUserMessageKeys() { + let vectors: [(Error, RadrootsUserMessageKey)] = [ + (RadrootsCaptureIntakeError.invalidRequest, .invalidRequest), + (RadrootsCaptureIntakeError.unavailable, .addMediaUnavailable), + (RadrootsCaptureIntakeError.permissionDenied, .permissionDenied), + (RadrootsCaptureIntakeError.userCancelled, .operationCancelled), + (RadrootsCaptureIntakeError.transientFailure, .tryAgain), + (RadrootsCaptureIntakeError.permanentFailure, .addMediaUnavailable), + (RadrootsBackgroundTransferError.invalidRequest, .invalidRequest), + (RadrootsBackgroundTransferError.unavailable, .backgroundTransferUnavailable), + (RadrootsBackgroundTransferError.transferFailure, .backgroundTransferFailed), + (RadrootsBackgroundTransferError.persistenceFailure, .secureStateUnavailable), + (RadrootsDocumentInterchangeError.notFound, .fileOperationFailed), + (RadrootsDocumentInterchangeError.userCancelled, .operationCancelled), + (RadrootsDocumentInterchangeError.permissionDenied, .permissionDenied), + (RadrootsAppLocalStateResetError.keychainFailure, .secureStateUnavailable), + (RadrootsAppleMediaPreparationError.preparationFailure, .addMediaUnavailable), + (RadrootsTelemetryError.invalidRequest, .invalidRequest), + (RadrootsExternalActionError.blockedByPolicy, .permissionDenied), + (RadrootsExternalActionError.transientFailure, .tryAgain), + (RadrootsAppleFileError.notFound, .fileOperationFailed), + (RadrootsAppleFileError.permissionDenied, .permissionDenied), + (RadrootsLocationServicesError.permissionDenied, .permissionDenied), + (RadrootsLocationServicesError.timeout, .locationUnavailable), + (RadrootsUserPresenceError.userCancelled, .operationCancelled), + (RadrootsUserPresenceError.unavailable, .userPresenceUnavailable), + (RadrootsBackgroundTaskError.schedulerFailure, .runtimeUnavailable), + (RadrootsAppleSecurityError.userCancelled, .operationCancelled), + (RadrootsAppleSecurityError.keychainFailure, .secureStateUnavailable), + (RadrootsAppleMobileStoreError.invalidPublicKey, .invalidRequest), + (RadrootsAppleMobileStoreError.fileSystemFailure, .secureStateUnavailable), + (RadrootsVerifiedArtifactAccessError.invalidDescriptor, .invalidRequest), + (RadrootsVerifiedArtifactAccessError.artifactCorrupt, .fileOperationFailed), + (RadrootsIdentityCustodyError.cryptographyFailed, .secureStateUnavailable), + ] + + for (error, expected) in vectors { + XCTAssertEqual( + RadrootsUserMessages.key(for: error, fallback: .runtimeOperationFailed), + expected + ) + } + } + + func testDependencyDiagnosticsAndRuntimeSafeMessagesCannotReachUserText() { + let canaries = [ + "nsec1step264canary", + "/Users/private/step264.sqlite", + "wss://private.invalid/relay", + ] + let errors: [Error] = [ + NSError( + domain: canaries[1], + code: 1, + userInfo: [NSLocalizedDescriptionKey: canaries.joined(separator: " ")] + ), + RadrootsRuntimeFailure.local( + operation: canaries[2], + code: "private.failure", + safeMessage: canaries[0] + ), + RadrootsConfigurationError.missing(canaries[1]), + RadrootsIdentityStoreError.custody(canaries[0]), + ] + + for error in errors { + let rendered = RadrootsUserMessages.text(for: error, fallback: .runtimeOperationFailed) + XCTAssertFalse(rendered.isEmpty) + for canary in canaries { + XCTAssertFalse(rendered.contains(canary)) + } + } + } +} diff --git a/project.yml b/project.yml @@ -13,7 +13,7 @@ packages: path: . RadrootsKit: url: https://github.com/radrootslabs/apple_kit.git - revision: e61386529422a6413a852f0233a196a88f4ded51 + revision: 35aedb6b54ff645b663fecff26082b3e91fcb232 targets: Radroots: @@ -24,6 +24,7 @@ targets: - path: Radroots/App/App.swift - path: Radroots/Resources/Assets.xcassets - path: Radroots/Resources/PrivacyInfo.xcprivacy + - path: Radroots/Resources/en.lproj/Localizable.strings settings: base: "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 @@ -53,6 +54,7 @@ targets: - path: RadrootsTests/RadrootsSupportingStoreTests.swift - path: RadrootsTests/RadrootsLifecycleTests.swift - path: RadrootsTests/RadrootsRemoteQualificationTests.swift + - path: RadrootsTests/RadrootsUserMessagesTests.swift settings: base: "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -228,6 +228,29 @@ then exit 1 fi +test -f "$repo_root/Radroots/Runtime/RadrootsUserMessages.swift" +test -f "$repo_root/Radroots/Resources/en.lproj/Localizable.strings" +grep -Fq 'enum RadrootsUserMessageKey: String, CaseIterable, Sendable' \ + "$repo_root/Radroots/Runtime/RadrootsUserMessages.swift" +grep -Fq 'RadrootsUserMessages.text(for: error, fallback:' \ + "$repo_root/Radroots/State/RadrootsAddStore.swift" +if rg -n '\(error as\? LocalizedError\)|\.localizedDescription' \ + "$repo_root/Radroots" --glob '*.swift' +then + echo "error: production UI projects a raw localized dependency error" >&2 + exit 1 +fi +if rg -n 'failure\.safeMessage' \ + "$repo_root/Radroots/App" \ + "$repo_root/Radroots/State" \ + "$repo_root/Radroots/Views" \ + "$repo_root/Radroots/Runtime/RadrootsLifecycleCoordinator.swift" \ + --glob '*.swift' +then + echo "error: presentation state bypasses the governed user-message catalog" >&2 + exit 1 +fi + for resolved in \ "$repo_root/Package.resolved" \ "$repo_root/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"