mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
321 lines
10 KiB
Swift
321 lines
10 KiB
Swift
//
|
|
// FirmwareFile.swift
|
|
// Meshtastic
|
|
//
|
|
// Created by jake on 12/13/25.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import CoreData
|
|
|
|
extension FirmwareFile {
|
|
enum FirmwareFileError: Error, LocalizedError {
|
|
case invalidFilenamePrefix
|
|
case parseError
|
|
case unknownFileType
|
|
case unknownTarget
|
|
case unknownArchitecture
|
|
case unknownVersion
|
|
case unknownReleaseType
|
|
case unknownRemoteURL
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidFilenamePrefix:
|
|
return "Filename must start with `firmware-`."
|
|
case .parseError:
|
|
return "Unable to parse the components of the filename (target and version)."
|
|
case .unknownFileType:
|
|
return "Unknown file type. May not be a firmware file."
|
|
case .unknownTarget:
|
|
return "Unknown platformio target."
|
|
case .unknownArchitecture:
|
|
return "Unknown architecture."
|
|
case .unknownVersion:
|
|
return "Unknown version."
|
|
case .unknownReleaseType:
|
|
return "Unknown release type (stable/alpha)."
|
|
case .unknownRemoteURL:
|
|
return "Unknown remote URL."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Various Enums and constants
|
|
extension FirmwareFile {
|
|
enum DownloadStatus: Equatable {
|
|
case notDownloaded
|
|
case downloading
|
|
case downloaded
|
|
case error(String)
|
|
}
|
|
|
|
enum FirmwareType: String, Identifiable, CustomStringConvertible {
|
|
var id: String { rawValue }
|
|
var description: String { return rawValue }
|
|
|
|
case uf2 = ".uf2"
|
|
case bin = "-update.bin"
|
|
case otaZip = "-ota.zip"
|
|
}
|
|
|
|
static let localFirmwareStorageURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
static let remoteFirmwareURLPrefix = URL(string: "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/")!
|
|
}
|
|
|
|
class FirmwareFile: ObservableObject, Hashable, Equatable {
|
|
let localUrl: URL
|
|
let remoteUrl: URL?
|
|
let versionId: String
|
|
let platformioTarget: String
|
|
let releaseType: ReleaseType
|
|
@Published var status: DownloadStatus
|
|
let firmwareType: FirmwareType
|
|
let architecure: Architecture
|
|
let releaseNotes: String?
|
|
|
|
let versionMajor, versionMinor, versionPatch: Int
|
|
|
|
init(firmware: FirmwareReleaseEntity, hardware: DeviceHardwareEntity, type: FirmwareType? = nil) throws {
|
|
var target: String?
|
|
var architecture: Architecture?
|
|
|
|
// Thread safe operationt to get the target and architecture
|
|
// from the given DeviceHardwareEntity
|
|
if let context = hardware.managedObjectContext {
|
|
context.performAndWait {
|
|
target = hardware.platformioTarget
|
|
architecture = hardware.architecture.flatMap { Architecture(rawValue: $0) }
|
|
}
|
|
} else {
|
|
// Detached, not yet inserted NSManagedObject
|
|
target = hardware.platformioTarget
|
|
architecture = hardware.architecture.flatMap { Architecture(rawValue: $0) }
|
|
}
|
|
|
|
guard let target else { throw FirmwareFileError.unknownTarget }
|
|
self.platformioTarget = target
|
|
|
|
guard let architecture else { throw FirmwareFileError.unknownArchitecture }
|
|
self.architecure = architecture
|
|
|
|
// Thread safe operation to get the versionf`rom the given FirmwareReleaseEntity
|
|
var version: String?
|
|
var releaseType: ReleaseType?
|
|
var releaseNotes: String?
|
|
if let context = firmware.managedObjectContext {
|
|
context.performAndWait {
|
|
version = firmware.versionId
|
|
releaseType = firmware.releaseType.flatMap { ReleaseType(rawValue: $0) }
|
|
releaseNotes = firmware.releaseNotes
|
|
}
|
|
} else {
|
|
version = firmware.versionId
|
|
releaseType = firmware.releaseType.flatMap { ReleaseType(rawValue: $0) }
|
|
releaseNotes = firmware.releaseNotes
|
|
}
|
|
|
|
self.releaseNotes = releaseNotes
|
|
|
|
guard let version else { throw FirmwareFileError.unknownVersion }
|
|
self.versionId = version
|
|
|
|
let cleanString = version.hasPrefix("v") ? version.dropFirst() : Substring(version)
|
|
let parts = cleanString.split(separator: ".")
|
|
if parts.count >= 3 {
|
|
self.versionMajor = Int(parts[0]) ?? 0
|
|
self.versionMinor = Int(parts[1]) ?? 0
|
|
self.versionPatch = Int(parts[2]) ?? 0
|
|
} else {
|
|
throw FirmwareFileError.parseError
|
|
}
|
|
|
|
guard let releaseType else { throw FirmwareFileError.unknownReleaseType }
|
|
self.releaseType = releaseType
|
|
|
|
// Calculate the filename
|
|
// Regarding the force unwrap: validFilenameSuffixes should always return at least one type
|
|
let defaultFileType = FirmwareFile.validFilenameSuffixes(forArchitecture: architecture).first!
|
|
self.firmwareType = type ?? defaultFileType
|
|
let fileNameVersion = versionId.hasPrefix("v") ? String(versionId.dropFirst()) : versionId
|
|
let fileName = "firmware-\(target)-\(fileNameVersion)\(firmwareType)"
|
|
self.localUrl = FirmwareFile.localFirmwareStorageURL.appendingPathComponent(fileName)
|
|
self.remoteUrl = FirmwareFile.remoteFirmwareURLPrefix
|
|
.appendingPathComponent("firmware-\(fileNameVersion)")
|
|
.appendingPathComponent(fileName)
|
|
|
|
if FileManager.default.fileExists(atPath: localUrl.path) {
|
|
self.status = .downloaded
|
|
} else {
|
|
self.status = .notDownloaded
|
|
}
|
|
}
|
|
|
|
init(localFile url: URL) throws {
|
|
self.localUrl = url
|
|
|
|
let fileName = url.lastPathComponent
|
|
|
|
// Check Prefix
|
|
guard fileName.hasPrefix("firmware-") else {
|
|
throw FirmwareFileError.invalidFilenamePrefix
|
|
}
|
|
|
|
// Check and Strip Suffix (Extension)
|
|
// We strip the prefix and suffix first to isolate "<target>-<version>"
|
|
var coreName = String(fileName.dropFirst("firmware-".count))
|
|
|
|
if fileName.hasSuffix("-ota.zip") {
|
|
coreName = String(coreName.dropLast("-ota.zip".count))
|
|
self.firmwareType = .otaZip
|
|
} else if fileName.hasSuffix(".uf2") {
|
|
coreName = String(coreName.dropLast(".uf2".count))
|
|
self.firmwareType = .uf2
|
|
} else if fileName.hasSuffix(".bin") {
|
|
if fileName.hasSuffix("-update.bin") {
|
|
coreName = String(coreName.dropLast("-update.bin".count))
|
|
} else if fileName.hasSuffix(".bin") {
|
|
coreName = String(coreName.dropLast("-.bin".count))
|
|
}
|
|
self.firmwareType = .bin
|
|
} else {
|
|
// File does not match supported extensions
|
|
throw FirmwareFileError.unknownFileType
|
|
}
|
|
|
|
// Extract Target and Version
|
|
// Strategy: We assume the format is Target-Version.
|
|
// Since Targets can have hyphens (e.g. "esp32-s3"), but Versions usually don't contain
|
|
// the separating hyphen in this specific naming convention, we split by the *last* hyphen.
|
|
guard let lastHyphenIndex = coreName.lastIndex(of: "-") else {
|
|
throw FirmwareFileError.parseError
|
|
}
|
|
|
|
let target = String(coreName[..<lastHyphenIndex])
|
|
var version = String(coreName[coreName.index(after: lastHyphenIndex)...])
|
|
|
|
let cleanString = version.hasPrefix("v") ? version.dropFirst() : Substring(version)
|
|
let parts = cleanString.split(separator: ".")
|
|
if parts.count >= 3 {
|
|
self.versionMajor = Int(parts[0]) ?? 0
|
|
self.versionMinor = Int(parts[1]) ?? 0
|
|
self.versionPatch = Int(parts[2]) ?? 0
|
|
} else {
|
|
throw FirmwareFileError.parseError
|
|
}
|
|
|
|
if !version.hasPrefix("v") {
|
|
version = "v" + version
|
|
}
|
|
|
|
// Validation to ensure we didn't end up with empty strings
|
|
guard !target.isEmpty, !version.isEmpty else {
|
|
throw FirmwareFileError.parseError
|
|
}
|
|
|
|
self.versionId = version
|
|
self.platformioTarget = target
|
|
|
|
if FileManager.default.fileExists(atPath: url.path) {
|
|
self.status = .downloaded
|
|
} else {
|
|
self.status = .notDownloaded
|
|
}
|
|
|
|
// Look up the architecture for this file
|
|
let context = PersistenceController.shared.container.newBackgroundContext()
|
|
var architecture: Architecture?
|
|
context.performAndWait {
|
|
let hardwareFetchRequest = DeviceHardwareEntity.fetchRequest()
|
|
hardwareFetchRequest.predicate = NSPredicate(format: "platformioTarget == %@", target)
|
|
hardwareFetchRequest.fetchLimit = 1
|
|
let hardware = try? context.fetch(hardwareFetchRequest).first
|
|
architecture = hardware?.architecture.flatMap { Architecture(rawValue: $0) }
|
|
}
|
|
|
|
guard let architecture else { throw FirmwareFileError.unknownArchitecture }
|
|
self.architecure = architecture
|
|
|
|
// Determine release type
|
|
var releaseType: ReleaseType = .unlisted
|
|
var releaseNotes: String?
|
|
context.performAndWait {
|
|
let firmwareFetchRequest = FirmwareReleaseEntity.fetchRequest()
|
|
firmwareFetchRequest.predicate = NSPredicate(format: "versionId == %@", version)
|
|
firmwareFetchRequest.fetchLimit = 1
|
|
if let firmware = try? context.fetch(firmwareFetchRequest).first {
|
|
releaseType = firmware.releaseType.flatMap { ReleaseType(rawValue: $0) } ?? .unlisted
|
|
releaseNotes = firmware.releaseNotes
|
|
}
|
|
}
|
|
self.releaseType = releaseType
|
|
self.releaseNotes = releaseNotes
|
|
|
|
let fileNameVersion = versionId.hasPrefix("v") ? String(versionId.dropFirst()) : versionId
|
|
self.remoteUrl = FirmwareFile.remoteFirmwareURLPrefix
|
|
.appendingPathComponent("firmware-\(fileNameVersion)")
|
|
.appendingPathComponent(fileName)
|
|
}
|
|
|
|
@MainActor
|
|
func download() async throws {
|
|
guard let remoteUrl else {
|
|
throw FirmwareFileError.unknownRemoteURL
|
|
}
|
|
Task {
|
|
do {
|
|
let (tempLocalUrl, response) = try await URLSession.shared.download(from: remoteUrl)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
|
|
if FileManager.default.fileExists(atPath: localUrl.path) {
|
|
try FileManager.default.removeItem(at: localUrl)
|
|
}
|
|
|
|
try FileManager.default.moveItem(at: tempLocalUrl, to: localUrl)
|
|
|
|
self.status = .downloaded
|
|
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: localUrl)
|
|
self.status = .error(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func validFilenameSuffixes(forArchitecture: Architecture) -> [FirmwareType] {
|
|
switch forArchitecture {
|
|
case .esp32, .esp32C3, .esp32S3, .esp32C6:
|
|
return [.bin]
|
|
case .nrf52840:
|
|
return [.uf2, .otaZip]
|
|
case .rp2040:
|
|
return [.uf2]
|
|
}
|
|
}
|
|
|
|
static func == (lhs: FirmwareFile, rhs: FirmwareFile) -> Bool {
|
|
return lhs.localUrl == rhs.localUrl &&
|
|
lhs.remoteUrl == rhs.remoteUrl &&
|
|
lhs.versionId == rhs.versionId &&
|
|
lhs.platformioTarget == rhs.platformioTarget &&
|
|
lhs.releaseType == rhs.releaseType &&
|
|
lhs.firmwareType == rhs.firmwareType &&
|
|
lhs.architecure == rhs.architecure
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(localUrl)
|
|
hasher.combine(remoteUrl)
|
|
hasher.combine(versionId)
|
|
hasher.combine(platformioTarget)
|
|
hasher.combine(releaseType)
|
|
hasher.combine(firmwareType)
|
|
hasher.combine(architecure)
|
|
}
|
|
}
|
|
|