mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
396 lines
13 KiB
Swift
396 lines
13 KiB
Swift
|
|
//
|
||
|
|
// MeshtasticAPI.swift
|
||
|
|
// Meshtastic
|
||
|
|
//
|
||
|
|
// Created by Jake Bordens on 12/4/25.
|
||
|
|
//
|
||
|
|
|
||
|
|
import Foundation
|
||
|
|
import OSLog
|
||
|
|
import SwiftUI
|
||
|
|
import CoreData
|
||
|
|
|
||
|
|
extension MeshtasticAPI {
|
||
|
|
enum MeshtasticAPIError: Error, LocalizedError {
|
||
|
|
case timedOut(TimeInterval)
|
||
|
|
case unableToRetreviveJSON
|
||
|
|
case unableToFindOrCreateEntity
|
||
|
|
case unknownArchitecture
|
||
|
|
case unknownPlatformIOTarget
|
||
|
|
var errorDescription: String? {
|
||
|
|
switch self {
|
||
|
|
case .timedOut(let seconds):
|
||
|
|
return "The operation timed out after \(seconds) seconds."
|
||
|
|
case .unableToRetreviveJSON:
|
||
|
|
return "Unable to retreive device hardware information."
|
||
|
|
case .unableToFindOrCreateEntity:
|
||
|
|
return "Unable to find or create Core Data entity."
|
||
|
|
case .unknownArchitecture:
|
||
|
|
return "Unknown architecture."
|
||
|
|
case .unknownPlatformIOTarget:
|
||
|
|
return "Unknown platformio target."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class MeshtasticAPI: ObservableObject, @unchecked Sendable {
|
||
|
|
// Singleton Access
|
||
|
|
static let shared = {
|
||
|
|
MeshtasticAPI(container: PersistenceController.shared.container)
|
||
|
|
}()
|
||
|
|
|
||
|
|
// MARK: - Constants
|
||
|
|
static let deviceURLEndpoint = URL(string: "https://api.meshtastic.org/resource/deviceHardware")!
|
||
|
|
static let imageURLPrefix = URL(string: "https://flasher.meshtastic.org/img/devices/")!
|
||
|
|
static let firmwareURLEndpoint = URL(string: "https://api.meshtastic.org/github/firmware/list")!
|
||
|
|
|
||
|
|
// MARK: - Private properties
|
||
|
|
private let fileManager = FileManager.default
|
||
|
|
private let decoder = JSONDecoder()
|
||
|
|
private let container: NSPersistentContainer
|
||
|
|
|
||
|
|
@Published var isLoadingDeviceList: Bool = false
|
||
|
|
@Published var isLoadingFirmwareList: Bool = false
|
||
|
|
|
||
|
|
private init(container: NSPersistentContainer) {
|
||
|
|
self.container = container
|
||
|
|
Task.detached {
|
||
|
|
try? await self.refreshDevicesAPIData()
|
||
|
|
try? await self.refreshFirmwareAPIData()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Main Logic
|
||
|
|
|
||
|
|
func refreshFirmwareAPIData() async throws {
|
||
|
|
await MainActor.run {
|
||
|
|
self.isLoadingFirmwareList = true
|
||
|
|
}
|
||
|
|
|
||
|
|
let apiData = try await Self.firmwareURLEndpoint.data(timeout: 5.0)
|
||
|
|
|
||
|
|
let decodedFirmware = try decoder.decode(FirmwareReleases.self, from: apiData)
|
||
|
|
|
||
|
|
let stableVersions = Set(decodedFirmware.releases.stable.map { $0.id })
|
||
|
|
let alphaVersions = Set(decodedFirmware.releases.alpha.map { $0.id })
|
||
|
|
|
||
|
|
await withTaskGroup(of: Void.self) { group in
|
||
|
|
|
||
|
|
for stableRelease in decodedFirmware.releases.stable {
|
||
|
|
group.addTask {
|
||
|
|
await self.processFirmware(release: stableRelease, releaseType: .stable)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for alphaRelease in decodedFirmware.releases.alpha {
|
||
|
|
group.addTask {
|
||
|
|
await self.processFirmware(release: alphaRelease, releaseType: .alpha)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Anything that's left in stableVersions and alphaVersions is no longer present in the API and should be deleted.
|
||
|
|
let context = container.newBackgroundContext()
|
||
|
|
context.performAndWait {
|
||
|
|
let deleteRequest = FirmwareReleaseEntity.fetchRequest()
|
||
|
|
deleteRequest.predicate = Self.firmwareCompoundPredicate(stableVersions: stableVersions, alphaVersions: alphaVersions)
|
||
|
|
if let objectsToDelete = try? context.fetch(deleteRequest) {
|
||
|
|
for object in objectsToDelete {
|
||
|
|
Logger.services.info("Deleting orphaned firmware release: \(object.versionId ?? "unknown")")
|
||
|
|
context.delete(object)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Save the deletions if any
|
||
|
|
if context.hasChanges {
|
||
|
|
try? context.save()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Save the last update date for the firmware
|
||
|
|
UserDefaults.lastFirmwareAPIUpdate = Date()
|
||
|
|
|
||
|
|
await MainActor.run {
|
||
|
|
self.isLoadingFirmwareList = false
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|
||
|
|
|
||
|
|
func refreshDevicesAPIData() async throws {
|
||
|
|
await MainActor.run {
|
||
|
|
self.isLoadingDeviceList = true
|
||
|
|
}
|
||
|
|
|
||
|
|
// PHASE 1: Network (Async) - Get the JSON first
|
||
|
|
var apiData: Data?
|
||
|
|
do {
|
||
|
|
apiData = try await Self.deviceURLEndpoint.data(timeout: 5.0)
|
||
|
|
} catch {
|
||
|
|
Logger.services.error("Unable to fetch device hardware from network: \(error.localizedDescription, privacy: .public)")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback to local bundle
|
||
|
|
if apiData == nil {
|
||
|
|
if let bundledJsonURL = Bundle.main.url(forResource: "DeviceHardware.json", withExtension: nil) {
|
||
|
|
apiData = try? Data(contentsOf: bundledJsonURL)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
guard let finalData = apiData else {
|
||
|
|
throw MeshtasticAPIError.unableToRetreviveJSON
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode Swift Structs (Safe to do off the DB thread)
|
||
|
|
let decodedDevices = try decoder.decode([DeviceHardware].self, from: finalData)
|
||
|
|
|
||
|
|
// PHASE 2: Database (Sync) - Update Devices & Tags
|
||
|
|
let context = container.newBackgroundContext()
|
||
|
|
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||
|
|
|
||
|
|
// We will perform the bulk update and return a simple list of images we need to check next.
|
||
|
|
// We DO NOT do network calls for images inside this block.
|
||
|
|
try await context.perform {
|
||
|
|
|
||
|
|
// 1. Update Devices and Tags
|
||
|
|
for device in decodedDevices {
|
||
|
|
let fetchRequest = DeviceHardwareEntity.fetchRequest()
|
||
|
|
fetchRequest.predicate = NSPredicate(format: "platformioTarget == %@", device.platformioTarget)
|
||
|
|
fetchRequest.fetchLimit = 1
|
||
|
|
|
||
|
|
let existing = try? context.fetch(fetchRequest).first
|
||
|
|
let deviceEntity = existing ?? DeviceHardwareEntity(context: context)
|
||
|
|
|
||
|
|
// Update Properties
|
||
|
|
deviceEntity.hwModel = Int64(device.hwModel)
|
||
|
|
deviceEntity.hwModelSlug = device.hwModelSlug
|
||
|
|
deviceEntity.platformioTarget = device.platformioTarget
|
||
|
|
deviceEntity.architecture = device.architecture.rawValue
|
||
|
|
deviceEntity.activelySupported = device.activelySupported
|
||
|
|
deviceEntity.displayName = device.displayName
|
||
|
|
|
||
|
|
// Handle Tags (Helper function is now synchronous)
|
||
|
|
var tags = Set<DeviceHardwareTagEntity>()
|
||
|
|
if let tagList = device.tags {
|
||
|
|
for tagString in tagList {
|
||
|
|
// Safe because findOrCreateTag is synchronous and uses `context`
|
||
|
|
if let tagEntity = try? Self.findOrCreateTag(tag: tagString, context: context) {
|
||
|
|
tags.insert(tagEntity)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
deviceEntity.tags = tags as NSSet
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Cleanup Orphans
|
||
|
|
Self.deleteOrphanedTags(context: context)
|
||
|
|
|
||
|
|
// 3. Save Device Metadata
|
||
|
|
if context.hasChanges {
|
||
|
|
try context.save()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// PHASE 3: Images (Async Mixed)
|
||
|
|
// Now that the devices exist in DB, we process images one by one.
|
||
|
|
// We loop through the *Decoded Structs* (not DB objects) to get URLs.
|
||
|
|
await withTaskGroup(of: Void.self) { group in
|
||
|
|
for device in decodedDevices {
|
||
|
|
group.addTask {
|
||
|
|
guard let imagesList = device.images else { return }
|
||
|
|
for imageName in imagesList {
|
||
|
|
await self.processImage(imageName: imageName, platform: device.platformioTarget)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Final cleanup of images (Sync)
|
||
|
|
try await context.perform {
|
||
|
|
Self.deleteOrphanedImages(context: context)
|
||
|
|
if context.hasChanges { try context.save() }
|
||
|
|
}
|
||
|
|
|
||
|
|
await MainActor.run {
|
||
|
|
self.isLoadingDeviceList = false
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|
||
|
|
|
||
|
|
private func processFirmware(release: FirmwareRelease, releaseType: FirmwareRelease.ReleaseType) async {
|
||
|
|
let context = container.newBackgroundContext()
|
||
|
|
|
||
|
|
await context.perform {
|
||
|
|
let fetchRequest = FirmwareReleaseEntity.fetchRequest()
|
||
|
|
fetchRequest.predicate = NSPredicate(format: "versionId == %@", release.id)
|
||
|
|
fetchRequest.fetchLimit = 1
|
||
|
|
|
||
|
|
let existingRelease = (try? context.fetch(fetchRequest).first) ?? FirmwareReleaseEntity(context: context)
|
||
|
|
existingRelease.versionId = release.id
|
||
|
|
existingRelease.title = release.title
|
||
|
|
existingRelease.releaseNotes = release.releaseNotes
|
||
|
|
existingRelease.pageUrl = release.pageURL
|
||
|
|
existingRelease.releaseType = releaseType.rawValue
|
||
|
|
|
||
|
|
let cleanString = release.id.hasPrefix("v") ? release.id.dropFirst() : Substring(release.id)
|
||
|
|
let parts = cleanString.split(separator: ".")
|
||
|
|
if parts.count >= 3 {
|
||
|
|
existingRelease.versionMajor = Int32(parts[0]) ?? 0
|
||
|
|
existingRelease.versionMinor = Int32(parts[1]) ?? 0
|
||
|
|
existingRelease.versionPatch = Int32(parts[2]) ?? 0
|
||
|
|
}
|
||
|
|
|
||
|
|
try? context.save()
|
||
|
|
Logger.services.info("Saving firmware release \(release.id) in database.")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Handles the logic of checking ETag -> Checking DB -> Downloading -> Bundle Fallback -> Saving
|
||
|
|
private func processImage(imageName: String, platform: String ) async {
|
||
|
|
let url = Self.imageURLPrefix.appendingPathComponent(imageName)
|
||
|
|
|
||
|
|
// 1. Network: Try to get ETag (Optional - might fail if offline or timeout)
|
||
|
|
let remoteETag = try? await url.eTag()
|
||
|
|
|
||
|
|
let context = container.newBackgroundContext()
|
||
|
|
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||
|
|
|
||
|
|
// 2. DB: Check if we already have this version or a usable cached version
|
||
|
|
let dbStatus: (isUpToDate: Bool, hasData: Bool) = await context.perform {
|
||
|
|
let request = DeviceHardwareImageEntity.fetchRequest()
|
||
|
|
request.predicate = NSPredicate(format: "fileName == %@", imageName)
|
||
|
|
request.fetchLimit = 1
|
||
|
|
|
||
|
|
if let existing = try? context.fetch(request).first,
|
||
|
|
let data = existing.svgData, !data.isEmpty {
|
||
|
|
|
||
|
|
// A: If we have a remote tag, does it match?
|
||
|
|
if let rTag = remoteETag {
|
||
|
|
return (existing.eTag == rTag, true)
|
||
|
|
}
|
||
|
|
|
||
|
|
// B: We are offline (no remote ETag), but we have data. Keep it.
|
||
|
|
return (true, true)
|
||
|
|
}
|
||
|
|
|
||
|
|
// No data in DB
|
||
|
|
return (false, false)
|
||
|
|
}
|
||
|
|
|
||
|
|
if dbStatus.isUpToDate {
|
||
|
|
Logger.services.debug("Image \(imageName) is up to date (or cached offline).")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Acquire Data (Network Primary -> Bundle Secondary)
|
||
|
|
var dataToSave: Data?
|
||
|
|
var eTagToSave: String?
|
||
|
|
|
||
|
|
// A: Attempt Network Download (only if we successfully got an ETag previously)
|
||
|
|
if let rTag = remoteETag {
|
||
|
|
if let networkData = try? await url.data(timeout: 5.0) {
|
||
|
|
dataToSave = networkData
|
||
|
|
eTagToSave = rTag
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// B: Fallback to Bundle if Network failed or returned no data
|
||
|
|
if dataToSave == nil {
|
||
|
|
Logger.services.debug("Network unavailable or failed for \(imageName). Checking local bundle.")
|
||
|
|
|
||
|
|
// Look in the 'images' subdirectory
|
||
|
|
if let bundleURL = Bundle.main.url(forResource: imageName, withExtension: nil, subdirectory: "images"),
|
||
|
|
let bundleData = try? Data(contentsOf: bundleURL) {
|
||
|
|
|
||
|
|
dataToSave = bundleData
|
||
|
|
// We use "bundled" as a placeholder ETag.
|
||
|
|
// Next time the app runs with internet, "bundled" != "real_etag", forcing an update.
|
||
|
|
eTagToSave = "bundled"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. DB: Save Image and Link to Device
|
||
|
|
guard let finalData = dataToSave, let finalETag = eTagToSave else {
|
||
|
|
Logger.services.error("Could not find image \(imageName) in Network or Bundle.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
await context.perform {
|
||
|
|
// Find the Device (we must fetch it in THIS context)
|
||
|
|
let deviceReq = DeviceHardwareEntity.fetchRequest()
|
||
|
|
deviceReq.predicate = NSPredicate(format: "platformioTarget == %@", platform)
|
||
|
|
|
||
|
|
guard let deviceEntity = try? context.fetch(deviceReq).first else { return }
|
||
|
|
|
||
|
|
// Find or Create Image Entity
|
||
|
|
let imageReq = DeviceHardwareImageEntity.fetchRequest()
|
||
|
|
imageReq.predicate = NSPredicate(format: "fileName == %@", imageName)
|
||
|
|
|
||
|
|
let existingImg = try? context.fetch(imageReq).first
|
||
|
|
let imageEntity = existingImg ?? DeviceHardwareImageEntity(context: context)
|
||
|
|
|
||
|
|
imageEntity.fileName = imageName
|
||
|
|
imageEntity.eTag = finalETag
|
||
|
|
imageEntity.svgData = finalData
|
||
|
|
|
||
|
|
// Create Relationship
|
||
|
|
deviceEntity.addToImages(imageEntity)
|
||
|
|
|
||
|
|
try? context.save()
|
||
|
|
Logger.services.info("Saving \(imageName) in database. eTag=\(finalETag)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Helpers
|
||
|
|
|
||
|
|
// Removed @MainActor - this must run on the background context passed in
|
||
|
|
private static func findOrCreateTag(tag: String, context: NSManagedObjectContext) throws -> DeviceHardwareTagEntity {
|
||
|
|
let fetchRequest = DeviceHardwareTagEntity.fetchRequest()
|
||
|
|
fetchRequest.predicate = NSPredicate(format: "tag == %@", tag)
|
||
|
|
fetchRequest.fetchLimit = 1
|
||
|
|
|
||
|
|
if let existingTag = try context.fetch(fetchRequest).first {
|
||
|
|
return existingTag
|
||
|
|
}
|
||
|
|
|
||
|
|
let newTag = DeviceHardwareTagEntity(context: context)
|
||
|
|
newTag.tag = tag
|
||
|
|
return newTag
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func deleteOrphanedTags(context: NSManagedObjectContext) {
|
||
|
|
let req = DeviceHardwareTagEntity.fetchRequest()
|
||
|
|
req.predicate = NSPredicate(format: "devices.@count == 0")
|
||
|
|
if let tags = try? context.fetch(req) {
|
||
|
|
tags.forEach { context.delete($0) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func deleteOrphanedImages(context: NSManagedObjectContext) {
|
||
|
|
let req = DeviceHardwareImageEntity.fetchRequest()
|
||
|
|
req.predicate = NSPredicate(format: "device == nil")
|
||
|
|
if let images = try? context.fetch(req) {
|
||
|
|
images.forEach { context.delete($0) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper to build compound predicate for firmware deletion (selects orphans)
|
||
|
|
static func firmwareCompoundPredicate(stableVersions: Set<String>, alphaVersions: Set<String>) -> NSPredicate {
|
||
|
|
let stablePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||
|
|
NSPredicate(format: "releaseType == %@", FirmwareRelease.ReleaseType.stable.rawValue),
|
||
|
|
NSPredicate(format: "NOT (versionId IN %@)", stableVersions)
|
||
|
|
])
|
||
|
|
let alphaPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||
|
|
NSPredicate(format: "releaseType == %@", FirmwareRelease.ReleaseType.alpha.rawValue),
|
||
|
|
NSPredicate(format: "NOT (versionId IN %@)", alphaVersions)
|
||
|
|
])
|
||
|
|
return NSCompoundPredicate(orPredicateWithSubpredicates: [stablePredicate, alphaPredicate])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Image Manifest Decoding
|
||
|
|
private struct ImageManifest: Codable {
|
||
|
|
let files: [String: [String: String]]
|
||
|
|
let api_hash: String
|
||
|
|
}
|