Fix for UIObservationTrackingFeedbackLoopDetected

This commit is contained in:
Jake-B 2025-12-15 12:42:30 -05:00
parent 209ce72f92
commit 6dd250232c
5 changed files with 337 additions and 257 deletions

View file

@ -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" : {

View file

@ -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)
}
}

View file

@ -9,18 +9,18 @@ import SwiftUI
import CoreData
import SwiftDraw
struct DeviceHardwareImage<T>: View where T: BinaryInteger, T: CVarArg {
@Environment(\.managedObjectContext) var context
@FetchRequest var hardware: FetchedResults<DeviceHardwareEntity>
@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<DeviceHardwareEntity>
init(hwId: T) {
// Init for Integer ID
init<T>(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<T>: View where T: BinaryInteger, T: CVarArg {
)
}
var potentialImages: [DeviceHardwareImageEntity] {
var returnImages = [DeviceHardwareImageEntity]()
var seenFileNames = Set<String>()
for item in hardware {
if let imageList = item.images as? Set<DeviceHardwareImageEntity> {
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<DeviceHardwareEntity>
@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<String>()
// This traversal happens in the background task now
for item in hardware {
guard let imageList = item.images as? Set<DeviceHardwareImageEntity> 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)
}
}
}
}

View file

@ -7,15 +7,13 @@
import SwiftUI
struct SupportedHardwareBadge<T>: View where T: BinaryInteger, T: CVarArg {
let hwModelId: T
struct SupportedHardwareBadge: View {
@Environment(\.managedObjectContext) var context
@FetchRequest var hardware: FetchedResults<DeviceHardwareEntity>
@EnvironmentObject var meshtasticAPI: MeshtasticAPI
init(hwModelId: T) {
self.hwModelId = hwModelId
init<T>(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<T>: 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<T>: 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")

View file

@ -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<DeviceHardwareEntity>
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<DeviceHardwareImageEntity> ?? []
if let image = images.first,
let data = image.svgData,
let svg = SVG(data: data) {
return svg
}
return nil
}
}