RuntimeStatusView.swift (13053B)
1 import RadrootsKit 2 import SwiftUI 3 import UIKit 4 5 struct RuntimeStatusView: View { 6 let phase: TeraAppModel.Phase 7 let retry: () -> Void 8 let createIdentity: () -> Void 9 let importIdentity: (RadrootsIdentitySecretMaterial) -> Void 10 let unlockIdentity: () -> Void 11 let recoverIdentity: () -> Void 12 let applyConfigurationReconfiguration: () -> Void 13 @State private var showsIdentityImport = false 14 @Environment(\.dynamicTypeSize) private var dynamicTypeSize 15 16 var body: some View { 17 NavigationStack { 18 ScrollView { 19 VStack(spacing: 20) { 20 Image(systemName: symbolName) 21 .font(.system(size: 48, weight: .medium)) 22 .foregroundStyle(symbolColor) 23 .accessibilityHidden(true) 24 Text(title) 25 .font(.title2.weight(.semibold)) 26 .accessibilityAddTraits(.isHeader) 27 Text(detail) 28 .font(.body) 29 .foregroundStyle(.primary) 30 .multilineTextAlignment(.center) 31 if case let .failed(failure) = phase { 32 Text("Error code \(failure.code)") 33 .font(.caption.monospaced()) 34 .foregroundStyle(.secondary) 35 .accessibilityIdentifier("radroots.runtime.failure_code") 36 } 37 if case .identityRequired = phase { 38 Button("Create identity", action: createIdentity) 39 .buttonStyle(.borderedProminent) 40 .accessibilityIdentifier("radroots.identity.create") 41 Button("Import identity") { showsIdentityImport = true } 42 .buttonStyle(.bordered) 43 .accessibilityIdentifier("radroots.identity.import") 44 } else if case .identityLocked = phase { 45 Button("Unlock identity", action: unlockIdentity) 46 .buttonStyle(.borderedProminent) 47 .accessibilityIdentifier("radroots.identity.unlock") 48 } else if case .recoveryRequired = phase { 49 Button("Recover identity", action: recoverIdentity) 50 .buttonStyle(.borderedProminent) 51 .accessibilityIdentifier("radroots.identity.recover") 52 } else if case .configurationReconfigurationRequired = phase { 53 Button("Apply network configuration", action: applyConfigurationReconfiguration) 54 .buttonStyle(.borderedProminent) 55 .accessibilityIdentifier("radroots.configuration.reconfigure") 56 } else if case .failed = phase { 57 Button("Retry", action: retry) 58 .buttonStyle(.borderedProminent) 59 .accessibilityIdentifier("radroots.runtime.retry") 60 } else if canRecheckLocalState { 61 Button("Check local state again", action: retry) 62 .buttonStyle(.borderedProminent) 63 .accessibilityIdentifier("tera.runtime.recheck") 64 } 65 } 66 .frame(maxWidth: .infinity) 67 .padding(24) 68 } 69 .navigationTitle("Tera") 70 .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic) 71 .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize) 72 .tint(.primary) 73 .buttonBorderShape(.roundedRectangle(radius: 12)) 74 } 75 .sheet(isPresented: $showsIdentityImport) { 76 NavigationStack { 77 Form { 78 Section { 79 importInstruction("Enter an nsec or 64-character secret key.") 80 importInstruction("It is transferred directly to Apple custody.") 81 importInstruction("Secret input is never stored in view state.") 82 } 83 Section { 84 TeraSecureIdentityImportField { material in 85 showsIdentityImport = false 86 importIdentity(material) 87 } 88 } 89 } 90 .navigationTitle("Import identity") 91 .navigationBarTitleDisplayMode(.inline) 92 .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize) 93 .toolbar { 94 ToolbarItem(placement: .cancellationAction) { 95 Button { showsIdentityImport = false } label: { 96 Label("Cancel", systemImage: "xmark") 97 .labelStyle(.iconOnly) 98 .frame(minWidth: 44, minHeight: 44) 99 } 100 } 101 } 102 } 103 .tint(.primary) 104 .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium, .large]) 105 .interactiveDismissDisabled() 106 } 107 .accessibilityIdentifier("radroots.runtime.status") 108 } 109 110 private func importInstruction(_ text: String) -> some View { 111 TeraIdentityImportInstruction(text: text) 112 } 113 114 private var symbolName: String { 115 switch phase { 116 case .starting: "leaf" 117 case .identityRequired: "person.badge.key" 118 case .identityLocked: "lock" 119 case .protectedDataUnavailable: "lock.iphone" 120 case .recoveryRequired: "wrench.and.screwdriver" 121 case .corruptIdentity: "exclamationmark.shield" 122 case .configurationReconfigurationRequired: "arrow.triangle.2.circlepath" 123 case .running: "checkmark.circle" 124 case .failed: "exclamationmark.triangle" 125 case .stopped: "pause.circle" 126 } 127 } 128 129 var canRecheckLocalState: Bool { 130 switch phase { 131 case .protectedDataUnavailable, .corruptIdentity, .stopped: true 132 default: false 133 } 134 } 135 136 private var symbolColor: Color { 137 switch phase { 138 case .failed, .corruptIdentity: .red 139 case .configurationReconfigurationRequired: .orange 140 case .running: .green 141 default: .accentColor 142 } 143 } 144 145 private var title: String { 146 switch phase { 147 case .starting: "Starting Tera" 148 case .identityRequired: "Set up your identity" 149 case .identityLocked: "Unlock your identity" 150 case .protectedDataUnavailable: "Unlock this device" 151 case .recoveryRequired: "Recover your identity" 152 case .corruptIdentity: "Identity data needs repair" 153 case .configurationReconfigurationRequired: "Network configuration changed" 154 case .running: "Tera is ready" 155 case .failed: "Tera needs attention" 156 case .stopped: "Tera is paused" 157 } 158 } 159 160 private var detail: String { 161 switch phase { 162 case .starting: 163 "Preparing your local Tera data." 164 case .identityRequired: 165 "Create or import an identity to connect your local food network." 166 case .identityLocked: 167 "Your local Nostr secret remains protected until you explicitly unlock it." 168 case .protectedDataUnavailable: 169 "Protected local data is unavailable while this device is locked." 170 case .recoveryRequired: 171 TeraUserMessages.text(.secureStateUnavailable) 172 case .corruptIdentity: 173 TeraUserMessages.text(.secureStateUnavailable) 174 case .configurationReconfigurationRequired: 175 "Review and apply the new network configuration. Existing local identity and drafts are preserved." 176 case let .running(snapshot): 177 "Runtime \(snapshot.crateVersion) is connected to your local data." 178 case let .failed(failure): 179 TeraUserMessages.text(for: failure, fallback: .runtimeOperationFailed) 180 case .stopped: 181 "Your durable local work is safe." 182 } 183 } 184 } 185 186 struct TeraSecureIdentityImportField: UIViewRepresentable { 187 let submit: @MainActor (RadrootsIdentitySecretMaterial) -> Void 188 @State private var errorMessage: String? 189 190 func makeCoordinator() -> Coordinator { 191 Coordinator(errorChanged: { errorMessage = $0 }, submit: submit) 192 } 193 194 func makeUIView(context: Context) -> UIView { 195 let field = UITextField() 196 field.borderStyle = .roundedRect 197 field.font = .preferredFont(forTextStyle: .body) 198 field.adjustsFontForContentSizeCategory = true 199 field.isSecureTextEntry = true 200 field.textContentType = .password 201 field.autocapitalizationType = .none 202 field.autocorrectionType = .no 203 field.spellCheckingType = .no 204 field.returnKeyType = .done 205 field.placeholder = "Secret key" 206 field.accessibilityLabel = "Secret identity key" 207 field.accessibilityIdentifier = "radroots.identity.import.secret" 208 field.delegate = context.coordinator 209 210 let button = UIButton(type: .system) 211 var configuration = UIButton.Configuration.filled() 212 configuration.title = "Import securely" 213 button.configuration = configuration 214 button.titleLabel?.numberOfLines = 0 215 button.accessibilityIdentifier = "radroots.identity.import.submit" 216 button.addTarget(context.coordinator, action: #selector(Coordinator.submitIdentity), for: .touchUpInside) 217 218 let error = UILabel() 219 error.font = .preferredFont(forTextStyle: .footnote) 220 error.adjustsFontForContentSizeCategory = true 221 error.textColor = .secondaryLabel 222 error.numberOfLines = 0 223 error.accessibilityIdentifier = "radroots.identity.import.error" 224 225 let stack = UIStackView(arrangedSubviews: [field, button, error]) 226 stack.axis = .vertical 227 stack.spacing = 12 228 stack.translatesAutoresizingMaskIntoConstraints = false 229 let container = UIView() 230 container.addSubview(stack) 231 NSLayoutConstraint.activate([ 232 stack.leadingAnchor.constraint(equalTo: container.leadingAnchor), 233 stack.trailingAnchor.constraint(equalTo: container.trailingAnchor), 234 stack.topAnchor.constraint(equalTo: container.topAnchor), 235 stack.bottomAnchor.constraint(equalTo: container.bottomAnchor), 236 field.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), 237 button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), 238 ]) 239 context.coordinator.field = field 240 context.coordinator.errorLabel = error 241 return container 242 } 243 244 func updateUIView(_ uiView: UIView, context: Context) { 245 context.coordinator.errorLabel?.text = errorMessage 246 uiView.invalidateIntrinsicContentSize() 247 uiView.setNeedsLayout() 248 } 249 250 func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context _: Context) -> CGSize? { 251 uiView.systemLayoutSizeFitting( 252 CGSize(width: proposal.width ?? 320, height: 0), 253 withHorizontalFittingPriority: .required, 254 verticalFittingPriority: .fittingSizeLevel 255 ) 256 } 257 258 static func dismantleUIView(_: UIView, coordinator: Coordinator) { 259 coordinator.field?.text = nil 260 coordinator.field?.resignFirstResponder() 261 coordinator.field?.delegate = nil 262 coordinator.errorLabel?.text = nil 263 coordinator.field = nil 264 coordinator.errorLabel = nil 265 } 266 267 @MainActor 268 final class Coordinator: NSObject, UITextFieldDelegate { 269 weak var field: UITextField? 270 weak var errorLabel: UILabel? 271 private let submit: @MainActor (RadrootsIdentitySecretMaterial) -> Void 272 private let announce: @MainActor (String) -> Void 273 private let errorChanged: @MainActor (String?) -> Void 274 275 init( 276 announce: @escaping @MainActor (String) -> Void = { 277 UIAccessibility.post(notification: .announcement, argument: $0) 278 }, 279 errorChanged: @escaping @MainActor (String?) -> Void = { _ in }, 280 submit: @escaping @MainActor (RadrootsIdentitySecretMaterial) -> Void 281 ) { 282 self.announce = announce 283 self.errorChanged = errorChanged 284 self.submit = submit 285 } 286 287 func textFieldShouldReturn(_: UITextField) -> Bool { 288 submitIdentity() 289 return false 290 } 291 292 @objc func submitIdentity() { 293 guard let field else { return } 294 let input = field.text ?? "" 295 field.text = nil 296 do { 297 let material = try RadrootsIdentitySecretMaterial(importText: input) 298 errorLabel?.text = nil 299 errorChanged(nil) 300 submit(material) 301 } catch { 302 let message = "Enter a valid Nostr secret key." 303 errorLabel?.text = message 304 errorChanged(message) 305 field.becomeFirstResponder() 306 announce(message) 307 } 308 } 309 } 310 }