From 6dd250232c9e02b01dc4fe06e271eaf4811e67a2 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Mon, 15 Dec 2025 12:42:30 -0500 Subject: [PATCH] Fix for UIObservationTrackingFeedbackLoopDetected --- Localizable.xcstrings | 4 + .../Model/Firmware/FirmwareViewModel.swift | 14 +- .../Views/Helpers/DeviceHardwareImage.swift | 257 ++++++++------- .../Helpers/SupportedHardwareBadge.swift | 18 +- .../Views/Settings/Firmware/Firmware.swift | 301 ++++++++++-------- 5 files changed, 337 insertions(+), 257 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 1525e229..5261cb00 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -16744,6 +16744,10 @@ } } }, + "Hardware not found for %@" : { + "comment" : "A label indicating that the view is displaying a message because it couldn't find any hardware information for the current node. The argument is the name of the node.", + "isCommentAutoGenerated" : true + }, "Hazardous" : { "localizations" : { "it" : { diff --git a/Meshtastic/Model/Firmware/FirmwareViewModel.swift b/Meshtastic/Model/Firmware/FirmwareViewModel.swift index be8a32a1..40b1701b 100644 --- a/Meshtastic/Model/Firmware/FirmwareViewModel.swift +++ b/Meshtastic/Model/Firmware/FirmwareViewModel.swift @@ -106,13 +106,15 @@ class FirmwareViewModel: ObservableObject { Logger.services.error("Error loading firmware files: \(error)") } - // Keep the list sorted by version, with deterministic ordering of the firmware type - self.firmwareFiles = newFirmwareList.values.sorted { - if ($0.versionMajor, $0.versionMinor, $0.versionPatch) == ($1.versionMajor, $1.versionMinor, $1.versionPatch) { - // If versions are equal, sort by firmwareType (assuming it's String or Comparable) - return String(describing: $0.firmwareType) < String(describing: $1.firmwareType) + Task { @MainActor in + // Keep the list sorted by version, with deterministic ordering of the firmware type + self.firmwareFiles = newFirmwareList.values.sorted { + if ($0.versionMajor, $0.versionMinor, $0.versionPatch) == ($1.versionMajor, $1.versionMinor, $1.versionPatch) { + // If versions are equal, sort by firmwareType (assuming it's String or Comparable) + return String(describing: $0.firmwareType) < String(describing: $1.firmwareType) + } + return ($0.versionMajor, $0.versionMinor, $0.versionPatch) > ($1.versionMajor, $1.versionMinor, $1.versionPatch) } - return ($0.versionMajor, $0.versionMinor, $0.versionPatch) > ($1.versionMajor, $1.versionMinor, $1.versionPatch) } } diff --git a/Meshtastic/Views/Helpers/DeviceHardwareImage.swift b/Meshtastic/Views/Helpers/DeviceHardwareImage.swift index 9711a054..8d170e18 100644 --- a/Meshtastic/Views/Helpers/DeviceHardwareImage.swift +++ b/Meshtastic/Views/Helpers/DeviceHardwareImage.swift @@ -9,18 +9,18 @@ import SwiftUI import CoreData import SwiftDraw -struct DeviceHardwareImage: View where T: BinaryInteger, T: CVarArg { - @Environment(\.managedObjectContext) var context - @FetchRequest var hardware: FetchedResults - @EnvironmentObject var meshtasticAPI: MeshtasticAPI +// 1. THE LOADER (Public API) +// Responsibilities: Construct the FetchRequest only. +// It creates no heavy objects and runs no logic in init. +struct DeviceHardwareImage: View { - // This closure lets the caller define modifiers on the Image - @State private var gridSize: CGSize = .zero + // We hold the fetch request here + @FetchRequest var hardwareResults: FetchedResults - init(hwId: T) { - + // Init for Integer ID + init(hwId: T) where T: BinaryInteger, T: CVarArg { let predicate = NSPredicate(format: "hwModel == %d", hwId) - _hardware = FetchRequest( + _hardwareResults = FetchRequest( entity: DeviceHardwareEntity.entity(), sortDescriptors: [NSSortDescriptor(key: "hwModelSlug", ascending: true)], predicate: predicate, @@ -28,148 +28,161 @@ struct DeviceHardwareImage: View where T: BinaryInteger, T: CVarArg { ) } - var potentialImages: [DeviceHardwareImageEntity] { - var returnImages = [DeviceHardwareImageEntity]() - var seenFileNames = Set() - for item in hardware { - if let imageList = item.images as? Set { - for image in imageList { - if image.svgData != nil { - let name = image.fileName ?? "" - if !seenFileNames.contains(name) { - seenFileNames.insert(name) - returnImages.append(image) - } - } - if returnImages.count >= 4 { - break - } - } - } - } - - // Sort to keep the order somewhat deterministic - return returnImages.sorted(by: {$0.fileName ?? "" < $1.fileName ?? ""}) + // Init for String Target + init(platformioTarget: String) { + let predicate = NSPredicate(format: "platformioTarget == %@", platformioTarget) + _hardwareResults = FetchRequest( + entity: DeviceHardwareEntity.entity(), + sortDescriptors: [NSSortDescriptor(key: "hwModelSlug", ascending: true)], + predicate: predicate, + animation: .default + ) } var body: some View { - // 1. Define the footprint. - // We use Color.clear so it takes up space but is invisible. - Color.clear - .aspectRatio(1, contentMode: .fit) // Enforce square aspect ratio (or change as needed) - // 2. Measure the size of this footprint using the new modifier - .onGeometryChange(for: CGSize.self) { proxy in - proxy.size - } action: { newValue in - gridSize = newValue + // Pass the raw fetched results to the logic layer + DeviceHardwareImageProcessor(hardware: hardwareResults) + } +} + +// 2. THE PROCESSOR (Internal) +// Responsibilities: Convert Core Data Entities into a flat array of images. +// This uses .task to step out of the Layout Loop. +private struct DeviceHardwareImageProcessor: View { + let hardware: FetchedResults + @EnvironmentObject var meshtasticAPI: MeshtasticAPI + + // We buffer the processed images in State. + // This prevents the Layout pass from triggering Core Data faults. + @State private var sortedImages: [DeviceHardwareImageEntity] = [] + + var body: some View { + DeviceHardwareImageLayout( + images: sortedImages, + isLoading: meshtasticAPI.isLoadingDeviceList + ) + .task(id: hardware.count) { + // Re-calculate only when the hardware list actually changes, + // NOT when the scrollview bounces or layout shifts. + self.sortedImages = processImages() + } + } + + // The heavy logic moved out of the computed property + private func processImages() -> [DeviceHardwareImageEntity] { + var returnImages = [DeviceHardwareImageEntity]() + var seenFileNames = Set() + + // This traversal happens in the background task now + for item in hardware { + guard let imageList = item.images as? Set else { continue } + + for image in imageList { + if image.svgData != nil { + let name = image.fileName ?? "" + if !seenFileNames.contains(name) { + seenFileNames.insert(name) + returnImages.append(image) + } + } + if returnImages.count >= 4 { break } } - // 3. Draw the actual content on top using the measured size + if returnImages.count >= 4 { break } + } + + return returnImages.sorted(by: { $0.fileName ?? "" < $1.fileName ?? "" }) + } +} + +// 3. THE LAYOUT (Pure UI) +// Responsibilities: Draw boxes. No Core Data knowledge. +private struct DeviceHardwareImageLayout: View { + let images: [DeviceHardwareImageEntity] + let isLoading: Bool + + var body: some View { + Color.clear + .aspectRatio(1, contentMode: .fit) .overlay { - let images = self.potentialImages - if images.count > 0, gridSize != .zero { - content(size: gridSize, images: self.potentialImages) - } else if meshtasticAPI.isLoadingDeviceList { - ProgressView() + if images.isEmpty { + if isLoading { + ProgressView() + } else { + Image("UNSET") + .resizable() + .scaledToFit() + } } else { - EmptyView() + grid(images: images) } } + .clipped() // Essential for ScrollView stability } @ViewBuilder - private func content(size: CGSize, images: [DeviceHardwareImageEntity]) -> some View { + private func grid(images: [DeviceHardwareImageEntity]) -> some View { let spacing: CGFloat = 10.0 + switch images.count { - case 0: - Image("UNSET") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: size.width, height: size.height) - case 1: - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: size.width, height: size.height) - } + SingleImageView(entity: images[0]) + case 2: HStack(spacing: spacing) { - ForEach(0..<2, id: \.self) { i in - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: (size.width - 2) / 2, - height: size.height) - } - } + SingleImageView(entity: images[0]) + SingleImageView(entity: images[1]) } case 3: - HStack(spacing: spacing) { - // Big image on the Left - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: (size.width * 0.6) - 1, - height: size.height) - } - - // Two stacked on the Right - VStack(spacing: spacing) { - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity, maxHeight: .infinity) // Flex fill - } - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity, maxHeight: .infinity) // Flex fill + GeometryReader { proxy in + HStack(spacing: spacing) { + SingleImageView(entity: images[0]) + .frame(width: floor(proxy.size.width * 0.6)) + + VStack(spacing: spacing) { + SingleImageView(entity: images[1]) + SingleImageView(entity: images[2]) } } - .frame(width: (size.width * 0.4) - 1, - height: size.height) } - default: // 4 items - let halfWidth = (size.width - 2) / 2 - let halfHeight = (size.height - 2) / 2 - + default: // 4 or more VStack(spacing: spacing) { HStack(spacing: spacing) { - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: halfWidth, height: halfHeight) - } - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: halfWidth, height: halfHeight) - } + SingleImageView(entity: images[0]) + SingleImageView(entity: images[1]) } HStack(spacing: spacing) { - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: halfWidth, height: halfHeight) - } - if let svgData = images[0].svgData, let svg = SVG(data: svgData) { - SVGView(svg: svg) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: halfWidth, height: halfHeight) - } + SingleImageView(entity: images[2]) + SingleImageView(entity: images[3]) } } } } } + +// 4. THE LEAF VIEW +// Responsibilities: safely load SVG data +private struct SingleImageView: View { + let entity: DeviceHardwareImageEntity + @State private var svg: SVG? + + var body: some View { + Group { + if let svg = svg { + SVGView(svg: svg) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Color.clear + } + } + .task { + // Parse SVG once, prevents lag during scroll/layout + if self.svg == nil, let data = entity.svgData { + self.svg = SVG(data: data) + } + } + } +} diff --git a/Meshtastic/Views/Helpers/SupportedHardwareBadge.swift b/Meshtastic/Views/Helpers/SupportedHardwareBadge.swift index 00a32699..15fe6893 100644 --- a/Meshtastic/Views/Helpers/SupportedHardwareBadge.swift +++ b/Meshtastic/Views/Helpers/SupportedHardwareBadge.swift @@ -7,15 +7,13 @@ import SwiftUI -struct SupportedHardwareBadge: View where T: BinaryInteger, T: CVarArg { - let hwModelId: T +struct SupportedHardwareBadge: View { @Environment(\.managedObjectContext) var context @FetchRequest var hardware: FetchedResults @EnvironmentObject var meshtasticAPI: MeshtasticAPI - init(hwModelId: T) { - self.hwModelId = hwModelId + init(hwModelId: T) where T: BinaryInteger, T: CVarArg { let predicate = NSPredicate(format: "hwModel == %d", hwModelId) _hardware = FetchRequest( entity: DeviceHardwareEntity.entity(), @@ -25,6 +23,16 @@ struct SupportedHardwareBadge: View where T: BinaryInteger, T: CVarArg { ) } + init(platformioTarget: String) { + let predicate = NSPredicate(format: "platformioTarget == %@", platformioTarget) + _hardware = FetchRequest( + entity: DeviceHardwareEntity.entity(), + sortDescriptors: [NSSortDescriptor(key: "hwModelSlug", ascending: true)], + predicate: predicate, + animation: .default + ) + } + var body: some View { switch hardware.count { case 1: @@ -50,7 +58,7 @@ struct SupportedHardwareBadge: View where T: BinaryInteger, T: CVarArg { } else { // Can't find this hardware in the database VStack { - Image(systemName:"questionmark.circle.fill") + Image(systemName: "questionmark.circle.fill") .font(.largeTitle) .foregroundStyle(.gray) Text("Unknown") diff --git a/Meshtastic/Views/Settings/Firmware/Firmware.swift b/Meshtastic/Views/Settings/Firmware/Firmware.swift index 856d968f..ced57922 100644 --- a/Meshtastic/Views/Settings/Firmware/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware/Firmware.swift @@ -8,55 +8,159 @@ import SwiftUI import StoreKit import OSLog +import SwiftDraw +// 1. THE WRAPPER +// This handles the fetching safely. It does not run logic in init. struct Firmware: View { - - private enum FirmwareTab { - case stable - case alpha - case downloaded - } - - @Environment(\.managedObjectContext) var context - @EnvironmentObject var accessoryManager: AccessoryManager let node: NodeInfoEntity - let hardware: DeviceHardwareEntity - @State var minimumVersion = "2.6.11" - @State var version = "" - @State private var currentDevice: DeviceHardware? - @State private var firmwareSelection = FirmwareTab.stable - - @EnvironmentObject var meshtasticAPI: MeshtasticAPI - - @StateObject var firmwareList: FirmwareViewModel + // Use SwiftUI's native FetchRequest mechanism + @FetchRequest var hardwareResults: FetchedResults init?(node: NodeInfoEntity?) { - guard let node else { return nil } + guard let node = node, let pioEnv = node.myInfo?.pioEnv else { return nil } self.node = node - let fetchRequest = DeviceHardwareEntity.fetchRequest() - guard let pioEnv = node.myInfo?.pioEnv else { return nil } - fetchRequest.predicate = NSPredicate(format: "platformioTarget == %@", pioEnv) - fetchRequest.fetchLimit = 1 - - // Can't use the @Environment because we don't have self yet. - let context = PersistenceController.shared.container.viewContext - guard let result = try? context.fetch(fetchRequest).first else { - return nil - } - hardware = result - _firmwareList = StateObject(wrappedValue: FirmwareViewModel(forHardware: result)) + let predicate = NSPredicate(format: "platformioTarget == %@", pioEnv) + _hardwareResults = FetchRequest( + entity: DeviceHardwareEntity.entity(), + sortDescriptors: [], + predicate: predicate, + animation: .default + ) } - var myVersion: String? { - return node.metadata?.firmwareVersion + var body: some View { + if let hardware = hardwareResults.first { + FirmwareContentView(node: node, hardware: hardware) + } else { + // Fallback content + List { + Text("Hardware not found for \(node.myInfo?.pioEnv ?? "unknown")") + } + } } +} + +// 2. THE CONTENT +// Decoupled from fetching logic. +private struct FirmwareContentView: View { + + private enum FirmwareTab { + case stable, alpha, downloaded + } + + @EnvironmentObject var accessoryManager: AccessoryManager + @EnvironmentObject var meshtasticAPI: MeshtasticAPI + + let node: NodeInfoEntity + let hardware: DeviceHardwareEntity + + // We can safely init the StateObject here because 'hardware' is passed in + @StateObject var firmwareList: FirmwareViewModel + @State private var firmwareSelection = FirmwareTab.stable + + init(node: NodeInfoEntity, hardware: DeviceHardwareEntity) { + self.node = node + self.hardware = hardware + _firmwareList = StateObject(wrappedValue: FirmwareViewModel(forHardware: hardware)) + } + + var body: some View { + List { + // SECTION 1: HERO + Section { + HStack { + SupportedHardwareBadge(hwModelId: hardware.hwModel) + Text("Device Model: \(hardware.displayName ?? "Unknown")") + .font(.largeTitle) + .fixedSize(horizontal: false, vertical: true) + } + + VStack { + FirmwareHeroImage(hardware: hardware) + .frame(height: 300) // Give List a hint of the height + .frame(maxWidth: .infinity) + } + + VStack(alignment: .leading) { + Text("Platform IO").font(.caption).foregroundColor(.secondary) + Text("\(node.myInfo?.pioEnv ?? "Unknown")") + } + VStack(alignment: .leading) { + Text("Architecture").font(.caption).foregroundColor(.secondary) + Text("\(hardware.architecture ?? "Unknown")") + } + VStack(alignment: .leading) { + Text("Current Firmware Version").font(.caption).foregroundColor(.secondary) + Text("\(node.metadata?.firmwareVersion ?? "Unknown")") + } + } + .listRowSeparator(.hidden) + + // SECTION 2: RELEASES + Section(header: releasesHeader, footer: lastUpdatedFooter) { + Picker("Firmware Version", selection: $firmwareSelection) { + Text("Stable").tag(FirmwareTab.stable) + Text("Alpha").tag(FirmwareTab.alpha) + Text("Downloaded").tag(FirmwareTab.downloaded) + }.pickerStyle(.segmented) + + // Extracted switch logic to keep body clean + firmwareRows + } + } + .navigationTitle("Firmware Updates") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Subviews @ViewBuilder - var firmwareLastUpdatedFooter: some View { - HStack(alignment: .firstTextBaseline, spacing: 0.0) { - if self.meshtasticAPI.isLoadingFirmwareList { + var firmwareRows: some View { + switch firmwareSelection { + case .stable: + let stables = firmwareList.mostRecentFirmware(forReleaseType: .stable) + ForEach(stables, id: \.localUrl) { release in + FirmwareRow(firmwareFile: release) + } + if let last = stables.last, let notes = last.releaseNotes { + NavigationLink("Release Notes") { + ScrollView { Text(notes).padding() } + .navigationTitle("\(last.versionId)") + } + } + case .alpha: + let alphas = firmwareList.mostRecentFirmware(forReleaseType: .alpha) + ForEach(alphas, id: \.localUrl) { release in + FirmwareRow(firmwareFile: release) + } + if let last = alphas.last, let notes = last.releaseNotes { + NavigationLink("Release Notes") { + ScrollView { Text(notes).padding() } + .navigationTitle("\(last.versionId)") + } + } + case .downloaded: + let downloads = firmwareList.downloadedFirmware(includeInProgressDownloads: true) + if downloads.isEmpty { + Text("No firmware has been downloaded for this device.") + } else { + ForEach(downloads, id: \.localUrl) { file in + FirmwareRow(firmwareFile: file) + } + .onDelete { offsets in + let files = offsets.map { downloads[$0] } + firmwareList.delete(files) + } + } + } + } + + var lastUpdatedFooter: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + if meshtasticAPI.isLoadingFirmwareList { Text("Updating now...") } else { if UserDefaults.lastFirmwareAPIUpdate == .distantPast { @@ -68,111 +172,60 @@ struct Firmware: View { } } - @ViewBuilder - var fimwareReleasesHeader: some View { + var releasesHeader: some View { HStack { Text("Firmware Releases") Spacer() if meshtasticAPI.isLoadingFirmwareList { ProgressView() } else { - Button { + Button("Check For Updates") { Task.detached { try? await meshtasticAPI.refreshFirmwareAPIData() } - } label: { - Text("Check For Updates") } } } } - - @StateObject private var dfuViewModel = DFUViewModel() +} + +// 3. THE ISOLATED HERO IMAGE +// This stops an infinite rendering loop. It loads the SVG data once into State, +// preventing the List layout pass from triggering Core Data faults repeatedly. +struct FirmwareHeroImage: View { + let hardware: DeviceHardwareEntity + @State private var svg: SVG? var body: some View { - List { - // Hero image of the node - Section { - HStack { - SupportedHardwareBadge(hwModelId: hardware.hwModel) - Text("Device Model: \(hardware.displayName ?? "Unknown")") - .font(.largeTitle) - .fixedSize(horizontal: false, vertical: true) - } - VStack(alignment: .center) { - DeviceHardwareImage(hwId: node.user?.hwModelId ?? 0) - .frame(width: 300, height: 300) - .cornerRadius(5) - }.frame(maxWidth: .infinity) // Make sure the center is honored by filling the width - VStack(alignment: .leading) { - Text("Platform IO").font(.caption).foregroundColor(.secondary) - Text("\(node.myInfo?.pioEnv, default: "Unknown")") - } - VStack(alignment: .leading) { - Text("Architecture").font(.caption).foregroundColor(.secondary) - Text("\(self.hardware.architecture, default: "Unknown")") - } - VStack(alignment: .leading) { - Text("Current Firmware Version").font(.caption).foregroundColor(.secondary) - Text("\(self.myVersion, default: "Unknown")") - } - }.listRowSeparator(.hidden) // Hides lines between rows - - Section(header: self.fimwareReleasesHeader, footer: self.firmwareLastUpdatedFooter) { - Picker("Firmware Version", selection: $firmwareSelection) { - Text("Stable").tag(FirmwareTab.stable) - Text("Alpha").tag(FirmwareTab.alpha) - Text("Downloaded").tag(FirmwareTab.downloaded) - }.pickerStyle(.segmented) - - switch firmwareSelection { - case .stable: - let stables = firmwareList.mostRecentFirmware(forReleaseType: .stable) - ForEach(stables, id: \.localUrl) { release in - FirmwareRow(firmwareFile: release) - } - if let lastStable = stables.last, let notes = lastStable.releaseNotes { - NavigationLink { - ScrollView { - Text(notes) - .padding() - }.navigationTitle("\(lastStable.versionId, default: "ReleaseNotes")") - } label: { - Text("Release Notes") - } - } - case .alpha: - let alphas = firmwareList.mostRecentFirmware(forReleaseType: .alpha) - ForEach(alphas, id: \.localUrl) { release in - FirmwareRow(firmwareFile: release) - } - if let lastAlpha = alphas.last, let notes = lastAlpha.releaseNotes { - NavigationLink { - ScrollView { - Text(notes) - .padding() - }.navigationTitle("\(lastAlpha.versionId, default: "ReleaseNotes")") - } label: { - Text("Release Notes") - } - } - case .downloaded: - let downloadedFirmware = firmwareList.downloadedFirmware(includeInProgressDownloads: true) - if downloadedFirmware.count > 0 { - ForEach(downloadedFirmware, id: \.localUrl) { firmwareFile in - FirmwareRow(firmwareFile: firmwareFile) - }.onDelete { offsets in - let filesToDelete = offsets.map { downloadedFirmware[$0] } - firmwareList.delete(filesToDelete) - } - } else { - Text("No firmware has been downloaded for this device.") - } - } + Group { + if let svg = svg { + SVGView(svg: svg) + .resizable() + .scaledToFit() + .frame(width: 300, height: 300) + .cornerRadius(5) + } else { + // Placeholder prevents List jumpiness while loading + Color.clear + .frame(width: 300, height: 300) } - .navigationTitle("Firmware Updates") - .navigationBarTitleDisplayMode(.inline) } + .task { + // Perform the Core Data relationship traversal off the main layout pass + if svg == nil { + self.svg = getSVG() + } + } + } + + private func getSVG() -> SVG? { + let images = hardware.images as? Set ?? [] + if let image = images.first, + let data = image.svgData, + let svg = SVG(data: data) { + return svg + } + return nil } }