commit 0e999d5c3ec19ebd3a609a6d16972cb232f201a6 parent 87d9297f95d1db6ad006329f0f10200da3b527f5 Author: triesap <tyson@radroots.org> Date: Thu, 1 Oct 2026 20:13:04 +0000 ios: complete permission and recovery affordances - Observe camera access through the pinned Apple adapter - Offer Settings and explicit capability rechecks - Preserve local editing and selective photo import - Qualify denied access and stale observation recovery Diffstat:
21 files changed, 414 insertions(+), 66 deletions(-)
diff --git a/Package.swift b/Package.swift @@ -46,6 +46,9 @@ let package = Package( "tera.xcconfig", ], sources: [ + "Runtime/TeraCameraAccess.swift", + "State/TeraMediaAccessStore.swift", + "Views/TeraMediaPermissionGuidance.swift", "Runtime/TeraAuthorVisibility.swift", "Runtime/TeraGeneratedAuthorVisibility.swift", "State/TeraVisibilityStore.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -113,6 +113,7 @@ BF0BF2D7F46217C4EBCA570C /* TeraCalendarPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFC5639BDCD2A5C1C27FE38 /* TeraCalendarPresentationTests.swift */; }; C0332D7E91580922DED3597C /* TeraNativeRecoveryScheduleTestStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC40593AB568A682B00D3B /* TeraNativeRecoveryScheduleTestStorage.swift */; }; C0C7D6F0CBCE262C348D3C3E /* TeraTodayReconciliationFFITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65B01F6F5F7E125148EC4FD /* TeraTodayReconciliationFFITests.swift */; }; + C20BDCF02F461EE53B14B53E /* TeraPermissionRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BF69ADD9540F6800FD64B0 /* TeraPermissionRecoveryTests.swift */; }; C219B5F4E24188E1116BF45B /* TeraRuntimeClientFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ED360C84532DA7088BFC6E /* TeraRuntimeClientFixtures.swift */; }; C3177E827FE62639579C3EF5 /* TeraRemoteQualificationUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CBDAE974FB8D7408108E69 /* TeraRemoteQualificationUITests.swift */; }; C344F7761A4AC79AB74705C9 /* TeraTodayCalendarScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2BE93A0818202625CAF1F5 /* TeraTodayCalendarScopeTests.swift */; }; @@ -286,6 +287,7 @@ DE21EEF3B1A13F1B5A10A3BF /* TeraComposerStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerStartupTests.swift; sourceTree = "<group>"; }; DE9E724A23014C6EA9042123 /* Tera.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Tera.app; sourceTree = BUILT_PRODUCTS_DIR; }; E0B9E33BFF3A26F00634BFFA /* TeraNativeExecutionFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraNativeExecutionFixture.swift; sourceTree = "<group>"; }; + E1BF69ADD9540F6800FD64B0 /* TeraPermissionRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraPermissionRecoveryTests.swift; sourceTree = "<group>"; }; E2C7C27B35A9F7176AA3416B /* TeraIdentityRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraIdentityRecoveryTests.swift; sourceTree = "<group>"; }; E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRemoteQualificationTests.swift; sourceTree = "<group>"; }; E55E47B15C42F13ABF2DFF27 /* TeraComposerAutosaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerAutosaveTests.swift; sourceTree = "<group>"; }; @@ -429,6 +431,7 @@ 74FCF1C4A8A7137623BBE223 /* TeraOpenedMediaCloseFixture.swift */, 9B10B1F3C7FCD9A72F04FC00 /* TeraOpenedMediaTests.swift */, 9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */, + E1BF69ADD9540F6800FD64B0 /* TeraPermissionRecoveryTests.swift */, 7AF60EF9DCFDC4ECE77AED67 /* TeraProductStartupTests.swift */, 3B954B7006CC5904C98138C6 /* TeraPublicationEvidenceTests.swift */, A195B277BEDC31DE3A0D7601 /* TeraPublicationNavigationTests.swift */, @@ -760,6 +763,7 @@ 2A68D8FEEC3B88D47D696589 /* TeraOpenedMediaCloseFixture.swift in Sources */, 6FED2D2255CC702B33CDC760 /* TeraOpenedMediaTests.swift in Sources */, 026981088C27CB79A49D9833 /* TeraOperationRecoveryTests.swift in Sources */, + C20BDCF02F461EE53B14B53E /* TeraPermissionRecoveryTests.swift in Sources */, C46F8EB5B196FFDD37E0279A /* TeraProductStartupTests.swift in Sources */, B9E397E21B1654C108AF74BF /* TeraPublicDisclosureTests.swift in Sources */, 093A6AC6C9B724E465507004 /* TeraPublicationEvidenceTests.swift in Sources */, diff --git a/Tera/Runtime/TeraAddMediaCoordinator.swift b/Tera/Runtime/TeraAddMediaCoordinator.swift @@ -1,13 +1,6 @@ import Foundation import RadrootsKit -struct TeraAddMediaSupport: Sendable, Equatable { - let library: Bool - let camera: Bool - - static let unavailable = Self(library: false, camera: false) -} - struct TeraAddBackgroundUploadReceipt: Sendable, Equatable { let identifier: String let draftID: String @@ -94,6 +87,7 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling { private let preparer: RadrootsAppleMediaPreparer private let transfer: any RadrootsBackgroundTransfer private let clock: TeraClock + private let cameraAccess: @Sendable () -> TeraCameraAccess /// Reserve before request preparation or native callbacks. A cancelled waiter /// releases this caller's admission; OS transfer state remains authoritative. private var activeUploadDrafts: Set<String> = [] @@ -137,13 +131,15 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling { picker: any RadrootsMediaPicker, preparer: RadrootsAppleMediaPreparer, transfer: any RadrootsBackgroundTransfer, - clock: TeraClock = .system + clock: TeraClock = .system, + cameraAccess: @escaping @Sendable () -> TeraCameraAccess = TeraCameraAccess.current ) { self.roots = roots self.picker = picker self.preparer = preparer self.transfer = transfer self.clock = clock + self.cameraAccess = cameraAccess } static func production( @@ -179,7 +175,8 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling { let value = try await picker.currentSupport() return TeraAddMediaSupport( library: value.importAvailable && value.supportedImportKinds.contains(.image), - camera: value.cameraCaptureAvailable && value.supportedCaptureKinds.contains(.image) + camera: value.cameraCaptureAvailable && value.supportedCaptureKinds.contains(.image), + cameraAccess: cameraAccess() ) } @@ -201,6 +198,11 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling { } func captureImage() async throws -> TeraPreparedMedia { + let capability = try await support() + guard capability.camera else { + throw TeraRuntimeFailure.local(operation: "add.media.camera", code: "ios.camera.unavailable", + safeMessage: capability.cameraAccess.guidance) + } let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot) defer { withExtendedLifetime(mediaUse) {} } let result = try await picker.captureMedia( diff --git a/Tera/Runtime/TeraCameraAccess.swift b/Tera/Runtime/TeraCameraAccess.swift @@ -0,0 +1,53 @@ +import RadrootsKit + +enum TeraCameraAccess: Sendable, Equatable, CaseIterable { + case authorized, notDetermined, denied, restricted, unavailable + + static func current() -> Self { + switch RadrootsApplePermissionStatusAdapters.live.cameraStatus() { + case .authorized: .authorized + case .notDetermined: .notDetermined + case .denied: .denied + case .restricted: .restricted + default: .unavailable + } + } + + var canCapture: Bool { + self == .authorized || self == .notDetermined + } + + var label: String { + switch self { + case .authorized: "Ready" + case .notDetermined: "Permission requested when used" + case .denied: "Access denied" + case .restricted: "Restricted on this device" + case .unavailable: "Unavailable" + } + } + + var guidance: String { + switch self { + case .authorized: "Camera access is available." + case .notDetermined: "Camera access is requested only when you choose Camera." + case .denied: "Camera access is off. You can enable it in Settings, then check access again." + case .restricted: "Camera access is restricted on this device. A device administrator or parental controls may manage this restriction." + case .unavailable: "A camera is unavailable on this device." + } + } +} + +struct TeraAddMediaSupport: Sendable, Equatable { + let library: Bool + let camera: Bool + let cameraAccess: TeraCameraAccess + + init(library: Bool, camera: Bool, cameraAccess: TeraCameraAccess = .authorized) { + self.library = library + self.camera = camera && cameraAccess.canCapture + self.cameraAccess = camera ? cameraAccess : .unavailable + } + + static let unavailable = Self(library: false, camera: false) +} diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift @@ -21,7 +21,11 @@ final class TeraAddStore: ObservableObject { let protection = TeraEditingProtection() @Published private(set) var mediaRecoveryMessage: String? @Published private(set) var state: TeraAddLoadState = .idle - @Published private(set) var mediaSupport: TeraAddMediaSupport = .unavailable + var mediaSupport: TeraAddMediaSupport { + mediaAccess.support + } + + private let mediaAccess: TeraMediaAccessStore @Published private(set) var blossomConfiguration: TeraBlossomConfigurationStatus? @Published private(set) var blossomEvidence: TeraBlossomEvidence? @Published private(set) var isCheckingBlossom = false @@ -67,6 +71,7 @@ final class TeraAddStore: ObservableObject { composer = TeraComposerAutosave(persistence: TeraComposerPersistence(client: runtimeClient).protectingMedia(media)) submissions = TeraSubmissionStore(client: runtimeClient, composer: composer, media: media) self.media = media + mediaAccess = TeraMediaAccessStore(media: media) self.identifier = identifier self.clock = clock self.observationDelay = observationDelay @@ -75,6 +80,7 @@ final class TeraAddStore: ObservableObject { identifier: identifier, clock: clock ) + mediaAccess.changed = { [weak self] in self?.objectWillChange.send() } submissions.changed = { [weak self] in self?.objectWillChange.send() } composer.stateChanged = { [weak self] in self?.composerState = $0 } protection.cancelled = { [weak self] in guard let self else { return }; generation = generation.invalidated() } @@ -158,16 +164,19 @@ final class TeraAddStore: ObservableObject { } func importPhotos() async { - guard canAddMedia, let media else { + await prepareMedia(capture: false) + } + + private func prepareMedia(capture: Bool) async { + guard canAddMedia, media != nil else { message = "Photo intake is unavailable." return } await perform { requestedGeneration in let remaining = self.mediaLimit - self.form.media.count - guard remaining > 0 else { return } - let imported = try await media.importImages(limit: remaining) + let prepared = try await self.mediaAccess.prepare(limit: remaining, capture: capture) try self.ensureCurrent(requestedGeneration) - self.form.media.append(contentsOf: imported.prefix(remaining)) + self.form.media.append(contentsOf: prepared.prefix(remaining)) self.message = "Photo prepared. Add descriptive text before publishing." } } @@ -192,11 +201,11 @@ final class TeraAddStore: ObservableObject { do { let evidence = try await runtimeClient.probeBlossom() try ensureCurrent(requestedGeneration) - let support = try await loadMediaSupport() + let support = try await mediaAccess.inspect() try ensureCurrent(requestedGeneration) guard probe == probeGeneration, serviceRequest == blossomGeneration else { return } blossomEvidence = evidence - mediaSupport = support + mediaAccess.accept(support) message = "Photo service is reachable." } catch { guard isCurrent(requestedGeneration), probe == probeGeneration, @@ -207,20 +216,6 @@ final class TeraAddStore: ObservableObject { } } - func capturePhoto() async { - guard canAddMedia, let media else { - message = "Camera intake is unavailable." - return - } - await perform { requestedGeneration in - let captured = try await media.captureImage() - try self.ensureCurrent(requestedGeneration) - guard self.form.media.count < self.mediaLimit else { return } - self.form.media.append(captured) - self.message = "Photo prepared. Add descriptive text before publishing." - } - } - func removeMedia(id: String) { guard isFormEditable else { return } generation = generation.invalidated() @@ -412,11 +407,6 @@ final class TeraAddStore: ObservableObject { } } - private func loadMediaSupport() async throws -> TeraAddMediaSupport { - guard let media else { return .unavailable } - return try await media.support() - } - @discardableResult private func refreshBlossomSnapshot() async -> Bool { let requestedGeneration = generation @@ -531,6 +521,15 @@ final class TeraAddStore: ObservableObject { /// Lifecycle remains in the same lexical owner so private callback guards and /// editing authority cannot be bypassed by a second controller. extension TeraAddStore { + func capturePhoto() async { + await prepareMedia(capture: true) + } + + func recheckMediaAccess() async { + guard !isWorking else { return } + await mediaAccess.recheck() + } + func configure(snapshot: TeraRuntimeSnapshot) { let updated = TeraPresentationConfiguration(snapshot: snapshot) let scope = TeraComposerScope(authorPublicKey: updated.publicKey, localNetworkID: updated.context.id) @@ -550,7 +549,7 @@ extension TeraAddStore { form = TeraAddPresentation.newForm(type: form.commandType, identifier: identifier, clock: clock) } state = .idle - mediaSupport = .unavailable + mediaAccess.accept(.unavailable) mediaRecoveryMessage = nil message = nil lastFailureCode = nil @@ -576,7 +575,7 @@ extension TeraAddStore { let draftRequest = appliedDraftsGeneration let serviceConfiguration = configuration do { - let loaded = try await TeraAddStartupSnapshot.load(client: runtimeClient, support: loadMediaSupport) { schemas in + let loaded = try await TeraAddStartupSnapshot.load(client: runtimeClient, support: mediaAccess.inspect) { schemas in guard self.isCurrent(requestedGeneration) else { return } self.schemas = schemas self.state = .ready @@ -594,7 +593,7 @@ extension TeraAddStore { drafts = TeraAddPresentation.sorted(loaded.drafts) } if serviceConfiguration == configuration { - mediaSupport = loaded.support + mediaAccess.accept(loaded.support) } state = .ready await submissions.refreshSelected() @@ -605,6 +604,7 @@ extension TeraAddStore { } func stop() { + mediaAccess.invalidate() submissions.stop() protection.cancel() recovery.stop() diff --git a/Tera/State/TeraMediaAccessStore.swift b/Tera/State/TeraMediaAccessStore.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Read-only capability observation and explicit intake, scoped to one account. +@MainActor +final class TeraMediaAccessStore { + private(set) var support = TeraAddMediaSupport.unavailable { + didSet { changed() } + } + + var changed: () -> Void = {} + private let media: (any TeraAddMediaHandling)? + private var generation = TeraSessionGeneration.initial + + init(media: (any TeraAddMediaHandling)?) { + self.media = media + } + + func accept(_ support: TeraAddMediaSupport) { + self.support = support + } + + func invalidate() { + generation = generation.invalidated() + } + + func inspect() async throws -> TeraAddMediaSupport { + try await media?.support() ?? .unavailable + } + + func prepare(limit: Int, capture: Bool) async throws -> [TeraPreparedMedia] { + guard limit > 0, let media else { throw TeraComposerAcknowledgment.unconfirmed } + if capture { + return try await [self.capture()] + } + let requested = generation + let result = try await media.importImages(limit: limit) + try ensureCurrent(requested) + return result + } + + func recheck() async { + invalidate() + let requested = generation + let result = try? await media?.support() + guard requested == generation, generation.isActive, !Task.isCancelled else { return } + support = result ?? .unavailable + } + + func capture() async throws -> TeraPreparedMedia { + let requested = generation + guard let media else { throw TeraComposerAcknowledgment.unconfirmed } + let capabilities = try await media.support() + try ensureCurrent(requested) + support = capabilities + guard support.camera else { + throw TeraRuntimeFailure.local(operation: "add.media.camera", code: "ios.camera.unavailable", + safeMessage: support.cameraAccess.guidance) + } + do { + let result = try await media.captureImage() + try ensureCurrent(requested) + return result + } catch { + let refreshed = try? await media.support() + try ensureCurrent(requested) + support = refreshed ?? .unavailable + throw error + } + } + + private func ensureCurrent(_ requested: TeraSessionGeneration) throws { + guard generation == requested, generation.isActive, !Task.isCancelled else { throw CancellationError() } + } +} diff --git a/Tera/Views/RuntimeStatusView.swift b/Tera/Views/RuntimeStatusView.swift @@ -55,6 +55,10 @@ struct RuntimeStatusView: View { Button("Retry", action: retry) .buttonStyle(.borderedProminent) .accessibilityIdentifier("radroots.runtime.retry") + } else if canRecheckLocalState { + Button("Check local state again", action: retry) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tera.runtime.recheck") } Spacer() } @@ -102,6 +106,13 @@ struct RuntimeStatusView: View { } } + var canRecheckLocalState: Bool { + switch phase { + case .protectedDataUnavailable, .corruptIdentity, .stopped: true + default: false + } + } + private var symbolColor: Color { switch phase { case .failed, .corruptIdentity: .red diff --git a/Tera/Views/TeraAddView.swift b/Tera/Views/TeraAddView.swift @@ -260,6 +260,7 @@ extension TeraAddView { mediaCameraButton } } + TeraMediaPermissionGuidance(support: store.mediaSupport) { await store.recheckMediaAccess() } } } diff --git a/Tera/Views/TeraMediaPermissionGuidance.swift b/Tera/Views/TeraMediaPermissionGuidance.swift @@ -0,0 +1,28 @@ +import SwiftUI +import UIKit + +struct TeraMediaPermissionGuidance: View { + let support: TeraAddMediaSupport + let recheck: @MainActor () async -> Void + @Environment(\.openURL) private var openURL + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(support.cameraAccess.guidance) + Text(support.library + ? "You can select photos from Library without full photo-library access, or keep editing and save without adding media." + : "Photo selection is unavailable. You can keep editing and save without adding media.") + if support.cameraAccess == .denied { + Button("Open camera settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + .accessibilityIdentifier("tera.add.camera.settings") + } + Button("Check camera access again") { Task { await recheck() } } + .accessibilityIdentifier("tera.add.camera.recheck") + } + .accessibilityElement(children: .contain) + } +} diff --git a/Tera/Views/TeraSettingsView.swift b/Tera/Views/TeraSettingsView.swift @@ -179,7 +179,7 @@ struct TeraSettingsView: View { "Photo library", value: addStore.mediaSupport.library ? "Ready" : "Unavailable" ) - LabeledContent("Camera", value: addStore.mediaSupport.camera ? "Ready" : "Unavailable") + LabeledContent("Camera", value: addStore.mediaSupport.cameraAccess.label) Button { Task { await addStore.checkPhotoService() } } label: { diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -94,17 +94,17 @@ { "bytes": 137212, "path": "source/aarch64-apple-darwin.json", - "sha256": "2d617ee8a7d9b589b0fdc72db78a3b5d23cb03f6ffee20450b6ff83e7b78e495" + "sha256": "f825968510e9d46fe06f0be6ea7b8af45726020db78a141c85527ace8c0bd78d" }, { "bytes": 137056, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "d7611b0e87609750520218db690ae5033f17dd0c286d5db76b6c3e3fde027ed3" + "sha256": "64c3ce643193068ea46a464fdc73b0638810afb8135731d3b7ebfa537e578e27" }, { "bytes": 137052, "path": "source/aarch64-apple-ios.json", - "sha256": "1d1978fa7ea059af6b4699342c3483664b3d8f13b216f87155b700ac9d59508d" + "sha256": "daf8f6ce3d8ef68ae5eea9ffa9955cc7e23434095740f37d55a90aec38362189" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "2c978511dd6c2890c21cb8df21de64602e23c7f8" + "tree": "730344c702e337496a3979e05facd626d62535ab" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -176,17 +176,17 @@ { "bytes": 137212, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "2d617ee8a7d9b589b0fdc72db78a3b5d23cb03f6ffee20450b6ff83e7b78e495" + "sha256": "f825968510e9d46fe06f0be6ea7b8af45726020db78a141c85527ace8c0bd78d" }, { "bytes": 137056, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "d7611b0e87609750520218db690ae5033f17dd0c286d5db76b6c3e3fde027ed3" + "sha256": "64c3ce643193068ea46a464fdc73b0638810afb8135731d3b7ebfa537e578e27" }, { "bytes": 137052, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "1d1978fa7ea059af6b4699342c3483664b3d8f13b216f87155b700ac9d59508d" + "sha256": "daf8f6ce3d8ef68ae5eea9ffa9955cc7e23434095740f37d55a90aec38362189" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "2c978511dd6c2890c21cb8df21de64602e23c7f8" -manifest_sha256 = "05e77cf9ac9a7ef56e066ace898f20eb631972d1c45fdd544de1832b8b868fc8" +source_tree = "730344c702e337496a3979e05facd626d62535ab" +manifest_sha256 = "9b812a0aee55eec9eb733c52d445c17ee4796f68766b26b3bf7b4ea86f42959b" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -2913,13 +2913,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 185653, - "git_blob": "c94480a04b1d68bd59e58e09ce6c919299de50d1", + "bytes": 186011, + "git_blob": "01800d808f9a03d7fbe4265b34975e19c5d48b10", "mode": "100644", - "sha256": "0ac3ef11617b27192c0835135c39d79056141198f31368a8bbbf88cff502c57d" + "sha256": "4ff0635697621d4f3ba9754fd59e17b2556bb8f8f3d5b965cc2af9909f21ff7a" } }, "policy": "staged_inputs", - "tree": "2c978511dd6c2890c21cb8df21de64602e23c7f8" + "tree": "730344c702e337496a3979e05facd626d62535ab" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -2909,13 +2909,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 185653, - "git_blob": "c94480a04b1d68bd59e58e09ce6c919299de50d1", + "bytes": 186011, + "git_blob": "01800d808f9a03d7fbe4265b34975e19c5d48b10", "mode": "100644", - "sha256": "0ac3ef11617b27192c0835135c39d79056141198f31368a8bbbf88cff502c57d" + "sha256": "4ff0635697621d4f3ba9754fd59e17b2556bb8f8f3d5b965cc2af9909f21ff7a" } }, "policy": "staged_inputs", - "tree": "2c978511dd6c2890c21cb8df21de64602e23c7f8" + "tree": "730344c702e337496a3979e05facd626d62535ab" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -2909,13 +2909,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 185653, - "git_blob": "c94480a04b1d68bd59e58e09ce6c919299de50d1", + "bytes": 186011, + "git_blob": "01800d808f9a03d7fbe4265b34975e19c5d48b10", "mode": "100644", - "sha256": "0ac3ef11617b27192c0835135c39d79056141198f31368a8bbbf88cff502c57d" + "sha256": "4ff0635697621d4f3ba9754fd59e17b2556bb8f8f3d5b965cc2af9909f21ff7a" } }, "policy": "staged_inputs", - "tree": "2c978511dd6c2890c21cb8df21de64602e23c7f8" + "tree": "730344c702e337496a3979e05facd626d62535ab" } } diff --git a/TeraTests/TeraOfflineMediaTests.swift b/TeraTests/TeraOfflineMediaTests.swift @@ -199,9 +199,9 @@ struct OfflineMediaFixture { picker = OfflineMediaPicker(byteSize: UInt64(bytes.count), filename: filename, mediaType: mediaType) } - func coordinator() -> TeraAddMediaCoordinator { + func coordinator(cameraAccess: @escaping @Sendable () -> TeraCameraAccess = { .authorized }) -> TeraAddMediaCoordinator { TeraAddMediaCoordinator(roots: roots, picker: picker, preparer: RadrootsAppleMediaPreparer(roots: roots), - transfer: transfer, clock: .fixed(unixSeconds: 1_800_000_000)) + transfer: transfer, clock: .fixed(unixSeconds: 1_800_000_000), cameraAccess: cameraAccess) } func configuration(_ signer: ComposerForbiddenSigner) -> TeraRuntimeLaunchConfiguration { diff --git a/TeraTests/TeraPermissionRecoveryTests.swift b/TeraTests/TeraPermissionRecoveryTests.swift @@ -0,0 +1,157 @@ +import Foundation +@testable import TeraApp +import XCTest + +@MainActor +final class TeraPermissionRecoveryTests: XCTestCase { + func testSharedCoordinatorKeepsLibraryIndependentAndNeverCapturesDeniedRestrictedOrUnavailable() async throws { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + for access in TeraCameraAccess.allCases { + let coordinator = fixture.coordinator(cameraAccess: { access }) + let support = try await coordinator.support() + XCTAssertTrue(support.library) + XCTAssertEqual(support.cameraAccess, access) + XCTAssertEqual(support.camera, access == .authorized || access == .notDetermined) + let imported = try await coordinator.importImages(limit: 1) + XCTAssertEqual(imported.count, 1) + if support.camera { + let photo = try await coordinator.captureImage() + XCTAssertNil(photo.remoteURL) + } else { + do { + _ = try await coordinator.captureImage() + XCTFail("Denied capability cannot call the picker") + } catch { XCTAssertTrue(error is TeraRuntimeFailure) } + } + } + let captures = await fixture.picker.captureRequests + let imports = await fixture.picker.importRequests + let uploads = await fixture.transfer.enqueueCount + XCTAssertEqual(captures.count, 2) + XCTAssertEqual(imports.count, 5) + XCTAssertEqual(uploads, 0) + } + + func testDeniedCameraPreservesEditingAndSaveUntilExplicitSettingsRecheck() async throws { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + let permission = CameraPermissionProbe(.denied) + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator(cameraAccess: permission.read)) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + await store.start() + store.selectType(.createPhotoUpdate) + store.updateForm(\.content, "Keep this unfinished description") + let original = store.form + await store.capturePhoto() + await store.capturePhoto() + XCTAssertEqual(store.form, original) + XCTAssertEqual(store.mediaSupport.cameraAccess, .denied) + XCTAssertFalse(store.mediaSupport.camera) + XCTAssertTrue(store.mediaSupport.library) + await store.save() + XCTAssertEqual(store.savedComposer?.form.content, original.content) + let deniedCaptures = await fixture.picker.captureRequests + XCTAssertTrue(deniedCaptures.isEmpty) + permission.set(.authorized) + await store.recheckMediaAccess() + XCTAssertTrue(store.mediaSupport.camera) + XCTAssertEqual(store.form, original) + await store.capturePhoto() + XCTAssertEqual(store.form.media.count, 1) + XCTAssertEqual(store.form.content, original.content) + XCTAssertNil(store.form.media.first?.remoteURL) + let captures = await fixture.picker.captureRequests + XCTAssertEqual(captures.count, 1) + store.stop() + _ = try await client.stop() + } + + func testLatePermissionRecheckCannotOverwriteNewerReadOrChangedAccount() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let media = PausedPermissionMedia() + let store = TeraAddStore(runtimeClient: client, media: media) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + let first = ResourceTestPause() + await media.hold(first, access: .denied) + let old = Task { await store.recheckMediaAccess() } + await first.entered.wait() + await store.recheckMediaAccess() + XCTAssertTrue(store.mediaSupport.camera) + await first.resume.open() + await old.value + XCTAssertTrue(store.mediaSupport.camera) + let second = ResourceTestPause() + await media.hold(second, access: .authorized) + let stale = Task { await store.recheckMediaAccess() } + await second.entered.wait() + store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b")) + await second.resume.open() + await stale.value + XCTAssertEqual(store.mediaSupport, .unavailable) + XCTAssertNil(store.message) + store.stop() + _ = try await client.stop() + } + + func testLocalStateRecheckUsesRetryWithoutSubstitutingCustodyActions() { + let identity = TeraAppIdentity(state: .locked, identityHandle: nil, publicKeyHex: nil, + label: nil, signerGeneration: nil, recoveryCode: nil) + for phase: TeraSessionPhase in [.protectedDataUnavailable(identity), .corruptIdentity(identity), .stopped] { + XCTAssertTrue(status(phase).canRecheckLocalState) + } + for phase: TeraSessionPhase in [.starting, .identityRequired, .identityLocked(identity), .recoveryRequired(identity)] { + XCTAssertFalse(status(phase).canRecheckLocalState) + } + } + + private func status(_ phase: TeraSessionPhase) -> RuntimeStatusView { + RuntimeStatusView(phase: phase, retry: {}, createIdentity: {}, importIdentity: { _ in }, + unlockIdentity: {}, recoverIdentity: {}, applyConfigurationReconfiguration: {}) + } +} + +private final class CameraPermissionProbe: @unchecked Sendable { + private let lock = NSLock() + private var value: TeraCameraAccess + init(_ value: TeraCameraAccess) { + self.value = value + } + + func read() -> TeraCameraAccess { + lock.withLock { value } + } + + func set(_ value: TeraCameraAccess) { + lock.withLock { self.value = value } + } +} + +private actor PausedPermissionMedia: TeraAddMediaHandling { + private var pending: (ResourceTestPause, TeraCameraAccess)? + func hold(_ pause: ResourceTestPause, access: TeraCameraAccess) { + pending = (pause, access) + } + + func support() async -> TeraAddMediaSupport { + guard let request = pending else { return .init(library: true, camera: true) } + pending = nil + await request.0.wait() + return .init(library: true, camera: true, cameraAccess: request.1) + } + + func importImages(limit _: Int) throws -> [TeraPreparedMedia] { + throw TeraComposerAcknowledgment.unconfirmed + } + + func captureImage() throws -> TeraPreparedMedia { + throw TeraComposerAcknowledgment.unconfirmed + } + + func open(_: [TeraPreparedMedia]) throws -> TeraOpenedMedia { + throw TeraComposerAcknowledgment.unconfirmed + } +} diff --git a/project.yml b/project.yml @@ -98,6 +98,7 @@ targets: - path: TeraTests/TeraEditingReplacementFFITests.swift - path: TeraTests/TeraEditingOperationProtectionTests.swift - path: TeraTests/TeraOfflineMediaTests.swift + - path: TeraTests/TeraPermissionRecoveryTests.swift - path: TeraTests/TeraComposerStartupTests.swift - path: TeraTests/TeraRuntimeInvalidationTests.swift - path: TeraTests/TeraRuntimeBackpressureTests.swift diff --git a/release/provenance.json b/release/provenance.json @@ -2,11 +2,11 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "5d74060f97e52dad69ab7567c16d6b6b34acfab846f468450d89e2394a8a69d5", - "ffi_provenance_sha256": "05e77cf9ac9a7ef56e066ace898f20eb631972d1c45fdd544de1832b8b868fc8", + "ffi_provenance_sha256": "9b812a0aee55eec9eb733c52d445c17ee4796f68766b26b3bf7b4ea86f42959b", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "904302bddb22ac1e4f9e79ab1145e590a8eeac7dd75e42539fb72eb0b7ebd4b1", - "xcode_project_sha256": "8aa1ce02f3b2d1b99b50cd88cebeb21f45787c37a4cf14c8cef535e2c9d55b9c" + "xcode_project_sha256": "187071ae7a525306c4fc32595ba1edde5c8eb823b5587da906cc05d6d4025a3c" }, "disposition": "unsigned", "platforms": [ @@ -22,7 +22,7 @@ "lib_revision": "8dbf27459be1729709a0ea36bba7470e90479ca3", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "322eaec80d4b85ef9eb20da95ff95c4d26d8555ca358ba0c8eb890adf0b6671e", - "tera_ffi_source_tree": "2c978511dd6c2890c21cb8df21de64602e23c7f8", + "tera_ffi_source_tree": "730344c702e337496a3979e05facd626d62535ab", "xcode_package_lock_sha256": "4e29fed46339d5e382fa78ec85b61be4296455ece4ddc40602d793c049e673b3" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -721,6 +721,16 @@ ] }, { + "identifier": "RadrootsApplePermissionStatusAdapters.live.cameraStatus", + "category": "shared_apple_api", + "occurrences": [ + { + "path": "Tera/Runtime/TeraCameraAccess.swift", + "count": 1 + } + ] + }, + { "identifier": "RadrootsApplePreparedImage", "category": "shared_apple_api", "occurrences": [ @@ -1739,6 +1749,10 @@ "count": 1 }, { + "path": "Tera/Runtime/TeraCameraAccess.swift", + "count": 1 + }, + { "path": "Tera/Runtime/TeraComposerMediaOwnership.swift", "count": 1 }, diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json @@ -1 +1 @@ -[{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","location":{"line":21,"character":3,"file":"Tera/App/TeraAppModel.swift"},"severity":"error"},"text":" init("},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 373 lines","location":{"line":31,"character":7,"file":"Tera/Runtime/TeraGeneratedRuntimeBackend.swift"},"severity":"error"},"text":"final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1089","location":{"line":1089,"character":1,"file":"Tera/Runtime/TeraGeneratedRuntimeBackend.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 675 lines","location":{"line":3,"character":1,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":"actor TeraRuntimeClient {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","location":{"line":353,"character":3,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 749","location":{"line":749,"character":1,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1029","location":{"line":1029,"character":1,"file":"Tera/Runtime/TeraRuntimeModels.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 491 lines","location":{"line":4,"character":7,"file":"Tera/State/TeraAddStore.swift"},"severity":"error"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 702","location":{"line":702,"character":1,"file":"Tera/State/TeraAddStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","location":{"line":78,"character":1,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":"actor TeraConfigurationStore {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","location":{"line":148,"character":5,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 759","location":{"line":759,"character":1,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","location":{"line":39,"character":1,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":"actor TeraSessionStore {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 13","location":{"line":125,"character":11,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 452","location":{"line":452,"character":1,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","location":{"line":365,"character":1,"file":"Tera/Views/TeraSupportingViews.swift"},"severity":"error"},"text":"struct TeraSettingsView: View {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 629","location":{"line":629,"character":1,"file":"Tera/Views/TeraSupportingViews.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 607 lines","location":{"line":6,"character":7,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 394 lines","location":{"line":682,"character":1,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"actor AddBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Number of function parameters should be low.","ruleIdentifier":"function_parameter_count","ruleName":"Function Parameter Count","reason":"Function should have 5 parameters or less: it currently has 8","location":{"line":1020,"character":3,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":" func makeStatus("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1232","location":{"line":1232,"character":1,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","location":{"line":7,"character":7,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","location":{"line":45,"character":3,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","location":{"line":212,"character":3,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","location":{"line":4,"character":7,"file":"TeraTests/TeraRuntimeClientTests.swift"},"severity":"error"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","location":{"line":6,"character":7,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","location":{"line":7,"character":5,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 622","location":{"line":622,"character":1,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 252 lines","location":{"line":207,"character":9,"file":"TeraTests/TeraSupportingStoreTests.swift"},"severity":"error"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 486","location":{"line":486,"character":1,"file":"TeraTests/TeraSupportingStoreTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1365 lines","location":{"line":4,"character":7,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 65 lines","location":{"line":62,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","location":{"line":166,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","location":{"line":239,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 18","location":{"line":566,"character":11,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","location":{"line":1601,"character":10,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" static func environment("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 2077","location":{"line":2077,"character":1,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":"}"}] +[{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","location":{"line":21,"character":3,"file":"Tera/App/TeraAppModel.swift"},"severity":"error"},"text":" init("},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 373 lines","location":{"line":31,"character":7,"file":"Tera/Runtime/TeraGeneratedRuntimeBackend.swift"},"severity":"error"},"text":"final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1089","location":{"line":1089,"character":1,"file":"Tera/Runtime/TeraGeneratedRuntimeBackend.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 675 lines","location":{"line":3,"character":1,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":"actor TeraRuntimeClient {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","location":{"line":353,"character":3,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 749","location":{"line":749,"character":1,"file":"Tera/Runtime/TeraRuntimeClient.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1029","location":{"line":1029,"character":1,"file":"Tera/Runtime/TeraRuntimeModels.swift"},"severity":"error"},"text":"}"},{"violation":{"location":{"file":"Tera/State/TeraAddStore.swift","line":4,"character":7},"ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 481 lines","ruleIdentifier":"type_body_length","severity":"error"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 702","location":{"line":702,"character":1,"file":"Tera/State/TeraAddStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","location":{"line":78,"character":1,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":"actor TeraConfigurationStore {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","location":{"line":148,"character":5,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 759","location":{"line":759,"character":1,"file":"Tera/State/TeraConfigurationStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","location":{"line":39,"character":1,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":"actor TeraSessionStore {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 13","location":{"line":125,"character":11,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 452","location":{"line":452,"character":1,"file":"Tera/State/TeraSessionStore.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","location":{"line":365,"character":1,"file":"Tera/Views/TeraSupportingViews.swift"},"severity":"error"},"text":"struct TeraSettingsView: View {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 629","location":{"line":629,"character":1,"file":"Tera/Views/TeraSupportingViews.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 607 lines","location":{"line":6,"character":7,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 394 lines","location":{"line":682,"character":1,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"actor AddBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Number of function parameters should be low.","ruleIdentifier":"function_parameter_count","ruleName":"Function Parameter Count","reason":"Function should have 5 parameters or less: it currently has 8","location":{"line":1020,"character":3,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":" func makeStatus("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1232","location":{"line":1232,"character":1,"file":"TeraTests/TeraAddStoreTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","location":{"line":7,"character":7,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","location":{"line":45,"character":3,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","location":{"line":212,"character":3,"file":"TeraTests/TeraRemoteQualificationTests.swift"},"severity":"error"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","location":{"line":4,"character":7,"file":"TeraTests/TeraRuntimeClientTests.swift"},"severity":"error"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","location":{"line":6,"character":7,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","location":{"line":7,"character":5,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 622","location":{"line":622,"character":1,"file":"TeraTests/TeraStateMigrationTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 252 lines","location":{"line":207,"character":9,"file":"TeraTests/TeraSupportingStoreTests.swift"},"severity":"error"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 486","location":{"line":486,"character":1,"file":"TeraTests/TeraSupportingStoreTests.swift"},"severity":"error"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleIdentifier":"type_body_length","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1365 lines","location":{"line":4,"character":7,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 65 lines","location":{"line":62,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","location":{"line":166,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","location":{"line":239,"character":3,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 18","location":{"line":566,"character":11,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleIdentifier":"function_body_length","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","location":{"line":1601,"character":10,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":" static func environment("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleIdentifier":"file_length","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 2077","location":{"line":2077,"character":1,"file":"TeraUITests/TeraRemoteQualificationUITests.swift"},"severity":"error"},"text":"}"}]