From 3cd459b4bed7f44392aa765de797292d1f6f6c6e Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:07:02 -0700 Subject: [PATCH 1/4] feat(firmware): add verified event OTA installer --- .../EventFirmwareArtifactDownloader.swift | 169 +++++++ .../Model/EventFirmwareOTAContract.swift | 115 +++++ .../Model/EventFirmwareOTASelector.swift | 174 +++++++ .../Model/EventFirmwareOTAService.swift | 158 +++++++ Meshtastic/Persistence/MarketingCapture.swift | 19 + .../ESP32 OTA/ESP32OTAIntroSheet.swift | 21 +- .../Firmware/EventFirmwareInstallerView.swift | 430 ++++++++++++++++++ .../Views/Settings/Firmware/Firmware.swift | 114 ++++- .../Firmware/NRF DFU/NRFDFUSheet.swift | 13 + ...EventFirmwareArtifactDownloaderTests.swift | 216 +++++++++ .../EventFirmwareInstallerViewTests.swift | 57 +++ .../EventFirmwareOTAContractTests.swift | 180 ++++++++ .../EventFirmwareOTASelectorTests.swift | 293 ++++++++++++ .../EventFirmwareOTAServiceTests.swift | 86 ++++ 14 files changed, 2036 insertions(+), 9 deletions(-) create mode 100644 Meshtastic/Model/EventFirmwareArtifactDownloader.swift create mode 100644 Meshtastic/Model/EventFirmwareOTAContract.swift create mode 100644 Meshtastic/Model/EventFirmwareOTASelector.swift create mode 100644 Meshtastic/Model/EventFirmwareOTAService.swift create mode 100644 Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift create mode 100644 MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift create mode 100644 MeshtasticTests/EventFirmwareInstallerViewTests.swift create mode 100644 MeshtasticTests/EventFirmwareOTAContractTests.swift create mode 100644 MeshtasticTests/EventFirmwareOTASelectorTests.swift create mode 100644 MeshtasticTests/EventFirmwareOTAServiceTests.swift diff --git a/Meshtastic/Model/EventFirmwareArtifactDownloader.swift b/Meshtastic/Model/EventFirmwareArtifactDownloader.swift new file mode 100644 index 000000000..233e1570a --- /dev/null +++ b/Meshtastic/Model/EventFirmwareArtifactDownloader.swift @@ -0,0 +1,169 @@ +import CryptoKit +import Foundation + +enum EventFirmwareArtifactDownloadError: Error, Equatable { + case unexpectedResponseURL + case httpStatus(Int) + case byteCountMismatch + case checksumMismatch +} + +actor EventFirmwareArtifactDownloader { + typealias Download = @Sendable (URL, Int64) async throws -> (URL, URLResponse) + + private let cacheDirectory: URL + private let download: Download + + init( + cacheDirectory: URL = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0].appendingPathComponent("EventFirmware", isDirectory: true), + session: URLSession = .shared + ) { + self.cacheDirectory = cacheDirectory + download = { url, maximumByteCount in + let delegate = EventFirmwareBoundedDownloadDelegate( + maximumByteCount: maximumByteCount + ) + do { + return try await session.download( + for: URLRequest(url: url), + delegate: delegate + ) + } catch { + if delegate.exceededMaximumByteCount { + throw EventFirmwareArtifactDownloadError.byteCountMismatch + } + throw error + } + } + } + + init( + cacheDirectory: URL, + download: @escaping Download + ) { + self.cacheDirectory = cacheDirectory + self.download = download + } + + func prepare(_ artifact: EventFirmwareOTAArtifact) async throws -> URL { + try Task.checkCancellation() + try FileManager.default.createDirectory( + at: cacheDirectory, + withIntermediateDirectories: true + ) + + let destination = cacheDirectory.appendingPathComponent( + artifact.sha256.lowercased() + ).appendingPathExtension(artifact.format.fileExtension) + if FileManager.default.fileExists(atPath: destination.path) { + if (try? verify(file: destination, against: artifact)) == true { + return destination + } + try FileManager.default.removeItem(at: destination) + } + + let (downloadedURL, response) = try await download( + artifact.url, + artifact.byteCount + ) + try Task.checkCancellation() + guard response.url == artifact.url else { + throw EventFirmwareArtifactDownloadError.unexpectedResponseURL + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + throw EventFirmwareArtifactDownloadError.httpStatus(httpResponse.statusCode) + } + + let stagingURL = cacheDirectory.appendingPathComponent( + ".\(UUID().uuidString).partial" + ) + defer { + try? FileManager.default.removeItem(at: stagingURL) + } + try FileManager.default.moveItem(at: downloadedURL, to: stagingURL) + guard try verify(file: stagingURL, against: artifact) else { + throw EventFirmwareArtifactDownloadError.checksumMismatch + } + try FileManager.default.moveItem(at: stagingURL, to: destination) + return destination + } + + private func verify( + file: URL, + against artifact: EventFirmwareOTAArtifact + ) throws -> Bool { + let verification = try digestAndSize(of: file) + guard verification.byteCount == artifact.byteCount else { + throw EventFirmwareArtifactDownloadError.byteCountMismatch + } + return verification.sha256.caseInsensitiveCompare(artifact.sha256) == .orderedSame + } + + private func digestAndSize(of file: URL) throws -> (sha256: String, byteCount: Int64) { + let handle = try FileHandle(forReadingFrom: file) + defer { + try? handle.close() + } + var hasher = SHA256() + var byteCount: Int64 = 0 + while let chunk = try handle.read(upToCount: 64 * 1_024), !chunk.isEmpty { + try Task.checkCancellation() + byteCount += Int64(chunk.count) + hasher.update(data: chunk) + } + let sha256 = hasher.finalize().map { String(format: "%02x", $0) }.joined() + return (sha256, byteCount) + } +} + +private final class EventFirmwareBoundedDownloadDelegate: NSObject, + URLSessionDownloadDelegate, + @unchecked Sendable { + + private let maximumByteCount: Int64 + private let lock = NSLock() + private var exceeded = false + + var exceededMaximumByteCount: Bool { + lock.withLock { exceeded } + } + + init(maximumByteCount: Int64) { + self.maximumByteCount = maximumByteCount + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) {} + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + guard totalBytesWritten > maximumByteCount else { return } + lock.withLock { + exceeded = true + } + downloadTask.cancel() + } +} + +private extension EventFirmwareOTAArtifact.Format { + var fileExtension: String { + switch self { + case .bin: + return "bin" + case .otaZip: + return "zip" + } + } +} diff --git a/Meshtastic/Model/EventFirmwareOTAContract.swift b/Meshtastic/Model/EventFirmwareOTAContract.swift new file mode 100644 index 000000000..86d065003 --- /dev/null +++ b/Meshtastic/Model/EventFirmwareOTAContract.swift @@ -0,0 +1,115 @@ +import CryptoKit +import Foundation + +struct EventFirmwareOTAEnvelope: Codable, Equatable, Sendable { + var keyId: String + var payload: String + var signature: String +} + +struct EventFirmwareOTAContract: Codable, Equatable, Sendable { + let schemaVersion: Int + let releaseId: String + let edition: String + let version: String + let issuedAt: Date + let expiresAt: Date + let artifacts: [EventFirmwareOTAArtifact] + let standardArtifacts: [EventFirmwareOTAArtifact] +} + +struct EventFirmwareOTAArtifact: Codable, Equatable, Sendable { + enum Format: String, Codable, Sendable { + case bin + case otaZip + } + + let pioEnv: String + let hwModel: Int + let architecture: String + let format: Format + let url: URL + let sha256: String + let byteCount: Int64 + let minimumSourceVersion: String + let partitionRole: String? + let partitionScheme: String? + let dfuProtocol: String? + let minimumBootloaderVersion: String? +} + +enum EventFirmwareOTAContractError: Error, Equatable { + case malformedEnvelope + case unknownKey(String) + case invalidTrustedKey + case invalidSignature + case invalidPayload + case unsupportedSchema(Int) + case notYetValid + case invalidValidityWindow + case expired +} + +struct EventFirmwareOTAContractVerifier { + private let trustedKeys: [String: Data] + private let now: () -> Date + + init( + trustedKeys: [String: Data], + now: @escaping () -> Date = Date.init + ) { + self.trustedKeys = trustedKeys + self.now = now + } + + func verify(envelopeData: Data) throws -> EventFirmwareOTAContract { + let envelope: EventFirmwareOTAEnvelope + do { + envelope = try JSONDecoder().decode(EventFirmwareOTAEnvelope.self, from: envelopeData) + } catch { + throw EventFirmwareOTAContractError.malformedEnvelope + } + + guard let payload = Data(base64Encoded: envelope.payload), + let signature = Data(base64Encoded: envelope.signature) else { + throw EventFirmwareOTAContractError.malformedEnvelope + } + guard let trustedKey = trustedKeys[envelope.keyId] else { + throw EventFirmwareOTAContractError.unknownKey(envelope.keyId) + } + + let publicKey: Curve25519.Signing.PublicKey + do { + publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: trustedKey) + } catch { + throw EventFirmwareOTAContractError.invalidTrustedKey + } + guard publicKey.isValidSignature(signature, for: payload) else { + throw EventFirmwareOTAContractError.invalidSignature + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let contract: EventFirmwareOTAContract + do { + contract = try decoder.decode(EventFirmwareOTAContract.self, from: payload) + } catch { + throw EventFirmwareOTAContractError.invalidPayload + } + + guard contract.schemaVersion == 1 else { + throw EventFirmwareOTAContractError.unsupportedSchema(contract.schemaVersion) + } + guard contract.expiresAt > contract.issuedAt else { + throw EventFirmwareOTAContractError.invalidValidityWindow + } + let verificationDate = now() + guard contract.issuedAt <= verificationDate.addingTimeInterval(300) else { + throw EventFirmwareOTAContractError.notYetValid + } + guard contract.expiresAt > verificationDate else { + throw EventFirmwareOTAContractError.expired + } + return contract + } +} diff --git a/Meshtastic/Model/EventFirmwareOTASelector.swift b/Meshtastic/Model/EventFirmwareOTASelector.swift new file mode 100644 index 000000000..600c02a4b --- /dev/null +++ b/Meshtastic/Model/EventFirmwareOTASelector.swift @@ -0,0 +1,174 @@ +import Foundation + +struct EventFirmwareOTATarget: Equatable, Sendable { + let pioEnv: String + let hwModel: Int + let architecture: String + let firmwareVersion: String + let supportsOTA: Bool + let partitionScheme: String? + let bootloaderVersion: String? +} + +enum EventFirmwareOTAInstallPurpose: Equatable, Sendable { + case event + case standard +} + +struct EventFirmwareOTASelection: Equatable, Sendable { + let artifact: EventFirmwareOTAArtifact + let purpose: EventFirmwareOTAInstallPurpose +} + +enum EventFirmwareOTASelectionError: Error, Equatable { + case noExactTarget + case ambiguousTarget + case sourceFirmwareTooOld(minimum: String) + case bootloaderTooOld(minimum: String) + case unsupportedOTAPath + case unapprovedArtifactURL + case incompatibleArtifact +} + +struct EventFirmwareOTASelector { + private let allowedHosts: Set + + init(allowedHosts: Set = ["raw.githubusercontent.com"]) { + self.allowedHosts = allowedHosts + } + + func select( + from contract: EventFirmwareOTAContract, + for target: EventFirmwareOTATarget, + purpose: EventFirmwareOTAInstallPurpose + ) throws -> EventFirmwareOTASelection { + let candidates = purpose == .event ? contract.artifacts : contract.standardArtifacts + let matchingArtifacts = candidates.filter { + $0.pioEnv == target.pioEnv && + $0.hwModel == target.hwModel && + $0.architecture == target.architecture + } + guard !matchingArtifacts.isEmpty else { + throw EventFirmwareOTASelectionError.noExactTarget + } + guard matchingArtifacts.count == 1, let artifact = matchingArtifacts.first else { + throw EventFirmwareOTASelectionError.ambiguousTarget + } + guard target.supportsOTA else { + throw EventFirmwareOTASelectionError.unsupportedOTAPath + } + guard isVersion(target.firmwareVersion, atLeast: artifact.minimumSourceVersion) else { + throw EventFirmwareOTASelectionError.sourceFirmwareTooOld( + minimum: artifact.minimumSourceVersion + ) + } + guard isApprovedArtifactURL(artifact.url) else { + throw EventFirmwareOTASelectionError.unapprovedArtifactURL + } + guard artifact.byteCount > 0, + artifact.sha256.count == 64, + artifact.sha256.allSatisfy(\.isHexDigit) else { + throw EventFirmwareOTASelectionError.incompatibleArtifact + } + + switch Architecture(rawValue: target.architecture) { + case .esp32, .esp32C3, .esp32S3, .esp32C6: + guard artifact.format == .bin, + artifact.partitionRole == "app0", + artifact.byteCount <= 16 * 1_024 * 1_024, + let artifactPartitionScheme = artifact.partitionScheme, + !artifactPartitionScheme.isEmpty, + artifactPartitionScheme == target.partitionScheme else { + throw EventFirmwareOTASelectionError.incompatibleArtifact + } + case .nrf52840: + guard artifact.format == .otaZip, + artifact.dfuProtocol == "nordic-legacy", + artifact.partitionRole == nil, + artifact.partitionScheme == nil, + artifact.byteCount <= 4 * 1_024 * 1_024, + let minimumBootloaderVersion = artifact.minimumBootloaderVersion else { + throw EventFirmwareOTASelectionError.incompatibleArtifact + } + guard let installedBootloaderVersion = target.bootloaderVersion else { + throw EventFirmwareOTASelectionError.bootloaderTooOld( + minimum: minimumBootloaderVersion + ) + } + guard isVersion(installedBootloaderVersion, atLeast: minimumBootloaderVersion) else { + throw EventFirmwareOTASelectionError.bootloaderTooOld( + minimum: minimumBootloaderVersion + ) + } + case .rp2040, .none: + throw EventFirmwareOTASelectionError.unsupportedOTAPath + } + + return EventFirmwareOTASelection(artifact: artifact, purpose: purpose) + } + + private func isApprovedArtifactURL(_ url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == "https", + let host = components.host?.lowercased(), + allowedHosts.contains(host), + components.user == nil, + components.password == nil, + components.fragment == nil, + components.query == nil else { + return false + } + + if host == "raw.githubusercontent.com" { + let path = components.path.split(separator: "/", omittingEmptySubsequences: true) + guard path.count >= 4 else { return false } + let revision = path[2] + return revision.count == 40 && revision.allSatisfy(\.isHexDigit) + } + return true + } + + private func isVersion(_ installed: String, atLeast minimum: String) -> Bool { + guard let installedParts = numericVersion(installed), + let minimumParts = numericVersion(minimum) else { + return false + } + let count = max(installedParts.count, minimumParts.count) + for index in 0.. minimumPart + } + } + return true + } + + private func numericVersion(_ version: String) -> [Int]? { + let normalized = version + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingPrefix("v") + let components = normalized.split(separator: ".", omittingEmptySubsequences: false) + guard components.count >= 3 else { return nil } + let core = components.prefix(3) + guard core.allSatisfy({ + !$0.isEmpty && $0.allSatisfy { $0.isASCII && $0.isNumber } + }) else { + return nil + } + let suffix = components.dropFirst(3) + guard suffix.allSatisfy({ + !$0.isEmpty && $0.allSatisfy { + $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") + } + }) else { + return nil + } + let numericCore = core.map { Int($0) } + guard numericCore.count == 3, + numericCore.allSatisfy({ $0 != nil }) else { + return nil + } + return numericCore.compactMap { $0 } + } +} diff --git a/Meshtastic/Model/EventFirmwareOTAService.swift b/Meshtastic/Model/EventFirmwareOTAService.swift new file mode 100644 index 000000000..77ac633cd --- /dev/null +++ b/Meshtastic/Model/EventFirmwareOTAService.swift @@ -0,0 +1,158 @@ +import CryptoKit +import Foundation + +enum EventFirmwareOTAUnavailableReason: Equatable, Sendable { + case contractUnavailable + case contractEditionMismatch + case noCompatibleArtifact + case sourceFirmwareTooOld(String) + case bootloaderTooOld(String) + case unsupportedOTAPath + case untrustedArtifact +} + +enum EventFirmwareOTAAvailability: Equatable, Sendable { + case available(EventFirmwareOTASelection) + case unavailable(EventFirmwareOTAUnavailableReason) +} + +struct EventFirmwareOTAAvailabilityResolver { + let contract: EventFirmwareOTAContract? + private let selector: EventFirmwareOTASelector + + init( + contract: EventFirmwareOTAContract?, + selector: EventFirmwareOTASelector = EventFirmwareOTASelector() + ) { + self.contract = contract + self.selector = selector + } + + func availability( + edition: String, + target: EventFirmwareOTATarget, + purpose: EventFirmwareOTAInstallPurpose + ) -> EventFirmwareOTAAvailability { + guard let contract else { + return .unavailable(.contractUnavailable) + } + guard contract.edition == edition else { + return .unavailable(.contractEditionMismatch) + } + do { + return .available(try selector.select( + from: contract, + for: target, + purpose: purpose + )) + } catch let error as EventFirmwareOTASelectionError { + switch error { + case .noExactTarget, .ambiguousTarget, .incompatibleArtifact: + return .unavailable(.noCompatibleArtifact) + case let .sourceFirmwareTooOld(minimum): + return .unavailable(.sourceFirmwareTooOld(minimum)) + case let .bootloaderTooOld(minimum): + return .unavailable(.bootloaderTooOld(minimum)) + case .unsupportedOTAPath: + return .unavailable(.unsupportedOTAPath) + case .unapprovedArtifactURL: + return .unavailable(.untrustedArtifact) + } + } catch { + return .unavailable(.noCompatibleArtifact) + } + } +} + +enum EventFirmwareOTAContractSource { + static var currentContract: EventFirmwareOTAContract? { + #if DEBUG && targetEnvironment(simulator) + return try? EventFirmwareOTADebugFixture.verifiedContract() + #else + return nil + #endif + } +} + +#if DEBUG +enum EventFirmwareOTADebugFixture { + static let eventPayload = Data("simulated DEFCON event firmware".utf8) + static let standardPayload = Data("simulated standard Meshtastic firmware".utf8) + + static func verifiedContract() throws -> EventFirmwareOTAContract { + let privateKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data((0..<32).map(UInt8.init)) + ) + let contract = EventFirmwareOTAContract( + schemaVersion: 1, + releaseId: "defcon-34-simulator", + edition: "DEFCON", + version: "2.8.0.b00d76f", + issuedAt: Date(timeIntervalSince1970: 1_750_000_000), + expiresAt: Date(timeIntervalSince1970: 2_100_000_000), + artifacts: [ + try artifact( + name: "event.bin", + payload: eventPayload + ) + ], + standardArtifacts: [ + try artifact( + name: "standard.bin", + payload: standardPayload + ) + ] + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + let payload = try encoder.encode(contract) + let envelope = EventFirmwareOTAEnvelope( + keyId: "event-simulator-1", + payload: payload.base64EncodedString(), + signature: try privateKey.signature(for: payload).base64EncodedString() + ) + let envelopeData = try JSONEncoder().encode(envelope) + return try EventFirmwareOTAContractVerifier( + trustedKeys: ["event-simulator-1": privateKey.publicKey.rawRepresentation] + ).verify(envelopeData: envelopeData) + } + + static func payload(for url: URL) -> Data? { + switch url.lastPathComponent { + case "event.bin": + return eventPayload + case "standard.bin": + return standardPayload + default: + return nil + } + } + + private static func artifact( + name: String, + payload: Data + ) throws -> EventFirmwareOTAArtifact { + guard let url = URL( + string: "https://raw.githubusercontent.com/meshtastic/firmware/" + + "0123456789abcdef0123456789abcdef01234567/\(name)" + ) else { + throw URLError(.badURL) + } + return EventFirmwareOTAArtifact( + pioEnv: "tbeam-s3-core", + hwModel: 12, + architecture: Architecture.esp32S3.rawValue, + format: .bin, + url: url, + sha256: SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined(), + byteCount: Int64(payload.count), + minimumSourceVersion: "2.7.0", + partitionRole: "app0", + partitionScheme: "8MB", + dfuProtocol: nil, + minimumBootloaderVersion: nil + ) + } +} +#endif diff --git a/Meshtastic/Persistence/MarketingCapture.swift b/Meshtastic/Persistence/MarketingCapture.swift index b4b8ea9ae..1067d28ff 100644 --- a/Meshtastic/Persistence/MarketingCapture.swift +++ b/Meshtastic/Persistence/MarketingCapture.swift @@ -55,10 +55,29 @@ enum MarketingCapture { return } simulateConnectedNode(accessoryManager) + configureEventFirmwarePreviewNode(accessoryManager, edition: edition) accessoryManager.firmwareEdition = edition accessoryManager.activeConnection?.device.firmwareVersion = previewFirmwareVersion(for: edition) } + private static func configureEventFirmwarePreviewNode( + _ accessoryManager: AccessoryManager, + edition: FirmwareEditions + ) { + let nodeNum: Int64 = 0x0A00_0000 + guard let node = getNodeInfo(id: nodeNum, context: accessoryManager.context) else { + return + } + + node.myInfo?.pioEnv = "tbeam-s3-core" + node.metadata?.hwModel = "LILYGO_TBEAM_S3_CORE" + node.metadata?.firmwareVersion = previewFirmwareVersion(for: edition) + node.user?.hwModel = "LILYGO_TBEAM_S3_CORE" + node.user?.hwModelId = 12 + node.user?.hwDisplayName = "LILYGO T-Beam Supreme" + try? accessoryManager.context.save() + } + /// Entry point, called once from `ContentView.task`. No-op unless `--marketing-capture` is set. /// Assumes the marketing data seed has already run in `MeshtasticAppleApp.init`. static func runIfNeeded(router: Router, accessoryManager: AccessoryManager) async { diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift index 065d7bc25..391526448 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift @@ -24,6 +24,7 @@ struct ESP32OTAIntroSheet: View { @Environment(\.modelContext) var context let binFileURL: URL + var expectedNodeNum: Int64? @State var showWifiUpdater = false @State var debugHost: String = "" @@ -34,6 +35,16 @@ struct ESP32OTAIntroSheet: View { accessoryManager.checkIsVersionSupported(forVersion: minimumOTAVersion) } + private var isExpectedDeviceActive: Bool { + guard let expectedNodeNum else { + return accessoryManager.activeDeviceNum != nil + } + return EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: expectedNodeNum, + activeNodeNum: accessoryManager.activeDeviceNum + ) + } + var body: some View { NavigationStack { List { @@ -103,6 +114,7 @@ struct ESP32OTAIntroSheet: View { .foregroundStyle(.secondary) Button(role: .destructive) { + guard isExpectedDeviceActive else { return } self.showWifiUpdater = true } label: { Text("I Know What I'm Doing") @@ -111,7 +123,7 @@ struct ESP32OTAIntroSheet: View { .controlSize(.large) .frame(maxWidth: .infinity) .cornerRadius(10) - .disabled(accessoryManager.activeDeviceNum == nil || !firmwareSupportsOTA) + .disabled(!isExpectedDeviceActive || !firmwareSupportsOTA) } .padding() .listRowBackground(Color(UIColor.tertiarySystemBackground)) @@ -139,6 +151,7 @@ struct ESP32OTAIntroSheet: View { .foregroundStyle(.secondary) Button(role: .destructive) { + guard isExpectedDeviceActive else { return } self.showBLEUpdater = true } label: { Text("I Know What I'm Doing") @@ -147,7 +160,7 @@ struct ESP32OTAIntroSheet: View { .controlSize(.large) .frame(maxWidth: .infinity) .cornerRadius(10) - .disabled(accessoryManager.activeDeviceNum == nil || !firmwareSupportsOTA) + .disabled(!isExpectedDeviceActive || !firmwareSupportsOTA) } .padding() .listRowBackground(Color(UIColor.tertiarySystemBackground)) @@ -158,14 +171,18 @@ struct ESP32OTAIntroSheet: View { #if DEBUG Section("Debug BLE") { Button("Manually Start BLE OTA") { + guard isExpectedDeviceActive else { return } self.showBLEUpdater = true } + .disabled(!isExpectedDeviceActive) } Section("Debug Wifi") { TextField("Device IP", text: $debugHost) Button("Manually Start WIFI OTA") { + guard isExpectedDeviceActive else { return } self.showWifiUpdater = true } + .disabled(!isExpectedDeviceActive) } #endif diff --git a/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift new file mode 100644 index 000000000..d0bc5c71d --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift @@ -0,0 +1,430 @@ +import Foundation +import SwiftUI + +enum EventFirmwareInstallerPrimaryAction: Equatable { + case install + case webFlasher +} + +enum EventFirmwareInstallerPolicy { + static func primaryAction( + for availability: EventFirmwareOTAAvailability + ) -> EventFirmwareInstallerPrimaryAction { + switch availability { + case .available: + return .install + case .unavailable: + return .webFlasher + } + } + + static func isExpectedDeviceActive( + expectedNodeNum: Int64, + activeNodeNum: Int64? + ) -> Bool { + activeNodeNum == expectedNodeNum + } +} + +struct EventFirmwareInstallerView: View { + typealias Install = (FirmwareFile.FirmwareType, URL) -> Void + + private enum PreparationState: Equatable { + case idle + case preparing + case failed(String) + } + + @EnvironmentObject private var accessoryManager: AccessoryManager + + let event: EventFirmwareEntity + let node: NodeInfoEntity + let hardware: DeviceHardwareEntity + let onInstall: Install + + private let downloader: EventFirmwareArtifactDownloader + @State private var preparationState: PreparationState = .idle + @State private var simulatorPreview: SimulatorFirmwarePreview? + @State private var preparationTask: Task? + + init( + event: EventFirmwareEntity, + node: NodeInfoEntity, + hardware: DeviceHardwareEntity, + downloader: EventFirmwareArtifactDownloader = EventFirmwareInstallerDependencies.downloader(), + onInstall: @escaping Install + ) { + self.event = event + self.node = node + self.hardware = hardware + self.downloader = downloader + self.onInstall = onInstall + } + + var body: some View { + List { + Section { + HStack(spacing: 14) { + EventFirmwareIcon( + edition: event.firmwareEdition ?? .vanilla, + iconURL: event.iconURL, + size: 48 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(event.displayName ?? event.firmwareEdition?.name ?? event.edition) + .font(.headline) + if let dateRange = event.formattedDateRange { + Text(dateRange) + .font(.subheadline) + .foregroundStyle(.secondary) + } + if let location = event.location, !location.isEmpty { + Text(location) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + + Section("Firmware") { + LabeledContent("Device", value: hardware.displayName ?? "Unknown") + .accessibilityElement(children: .combine) + LabeledContent("Target", value: target.pioEnv) + .accessibilityElement(children: .combine) + LabeledContent("Installed", value: target.firmwareVersion) + .accessibilityElement(children: .combine) + + if let eventVersion = event.firmwareVersion, !eventVersion.isEmpty { + LabeledContent("Event version", value: eventVersion) + .accessibilityElement(children: .combine) + } + + switch availability { + case let .available(selection): + Label( + selection.purpose == .event + ? "Signed event firmware is available for this exact device target." + : "Signed standard firmware is available for this exact device target.", + systemImage: "checkmark.shield.fill" + ) + .foregroundStyle(.green) + .font(.callout) + case let .unavailable(reason): + Label(unavailableMessage(for: reason), systemImage: "safari") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + Section { + primaryAction + + if case let .failed(message) = preparationState { + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .font(.callout) + } + } footer: { + Text(actionFooter) + } + } + .navigationTitle(installPurpose == .event ? "Event Firmware" : "Standard Firmware") + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $simulatorPreview) { preview in + SimulatorEventFirmwareProgressView(preview: preview) + } + .onDisappear { + preparationTask?.cancel() + preparationTask = nil + } + } + + @ViewBuilder + private var primaryAction: some View { + switch EventFirmwareInstallerPolicy.primaryAction(for: availability) { + case .install: + Button { + prepareVerifiedArtifact() + } label: { + HStack { + Label(actionTitle, systemImage: actionIcon) + Spacer() + if preparationState == .preparing { + ProgressView() + } + } + } + .disabled(preparationState == .preparing) + case .webFlasher: + Link(destination: webFlasherURL) { + HStack { + Label("Open Meshtastic Web Flasher", systemImage: "safari") + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var installPurpose: EventFirmwareOTAInstallPurpose { + accessoryManager.firmwareEdition.editionKey == event.edition ? .standard : .event + } + + private var availability: EventFirmwareOTAAvailability { + EventFirmwareOTAAvailabilityResolver( + contract: EventFirmwareOTAContractSource.currentContract + ).availability( + edition: event.edition, + target: target, + purpose: installPurpose + ) + } + + private var target: EventFirmwareOTATarget { + let architecture = hardware.architecture ?? "" + return EventFirmwareOTATarget( + pioEnv: node.myInfo?.pioEnv ?? hardware.platformioTarget ?? "", + hwModel: Int(node.user?.hwModelId ?? 0), + architecture: architecture, + firmwareVersion: node.metadata?.firmwareVersion + ?? "", + supportsOTA: supportsInAppOTA(architecture: architecture), + partitionScheme: hardware.partitionScheme, + bootloaderVersion: nil + ) + } + + private var actionTitle: String { + installPurpose == .event ? "Install Event Firmware" : "Return to Standard Firmware" + } + + private var actionIcon: String { + installPurpose == .event ? "calendar.badge.checkmark" : "arrow.uturn.backward.circle" + } + + private var actionFooter: String { + switch availability { + case .available: + return "The download is verified before the existing firmware installer is opened. Keep the app open and your device nearby during installation." + case .unavailable: + return "This app cannot verify a compatible in-app package for this exact device. The web flasher provides the supported installation path." + } + } + + private var webFlasherURL: URL { + URL(string: "https://flasher.meshtastic.org") ?? URL(fileURLWithPath: "/") + } + + private func supportsInAppOTA(architecture: String) -> Bool { + switch Architecture(rawValue: architecture) { + case .esp32, .esp32C3, .esp32S3, .esp32C6: + return true + case .nrf52840, .rp2040, .none: + return false + } + } + + private func unavailableMessage( + for reason: EventFirmwareOTAUnavailableReason + ) -> String { + switch reason { + case .contractUnavailable: + return "No trusted installation contract is currently published for this event." + case .contractEditionMismatch: + return "The published installation contract is for a different event." + case .noCompatibleArtifact: + return "No package is published for this exact device target." + case let .sourceFirmwareTooOld(minimum): + return "In-app installation requires device firmware \(minimum) or newer." + case let .bootloaderTooOld(minimum): + return "In-app installation requires bootloader \(minimum) or newer." + case .unsupportedOTAPath: + return "This device does not have an app-supported event firmware OTA path." + case .untrustedArtifact: + return "The available package does not meet the app's download trust policy." + } + } + + private func prepareVerifiedArtifact() { + guard case let .available(selection) = availability else { return } + let expectedNodeNum = node.num + guard EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: expectedNodeNum, + activeNodeNum: accessoryManager.activeDeviceNum + ) else { + preparationState = .failed( + "Reconnect to this device before preparing its firmware package." + ) + return + } + preparationState = .preparing + + preparationTask?.cancel() + preparationTask = Task { + do { + let localURL = try await downloader.prepare(selection.artifact) + try Task.checkCancellation() + await MainActor.run { + guard EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: expectedNodeNum, + activeNodeNum: accessoryManager.activeDeviceNum + ) else { + preparationState = .failed( + "The connected device changed. Select the event again for the current device." + ) + preparationTask = nil + return + } + preparationState = .idle + #if DEBUG && targetEnvironment(simulator) + simulatorPreview = SimulatorFirmwarePreview( + title: actionTitle, + fileName: localURL.lastPathComponent, + byteCount: selection.artifact.byteCount + ) + #else + onInstall(selection.artifact.format.firmwareType, localURL) + #endif + preparationTask = nil + } + } catch is CancellationError { + await MainActor.run { + preparationState = .idle + preparationTask = nil + } + } catch { + await MainActor.run { + preparationState = .failed( + "The firmware package could not be downloaded and verified." + ) + preparationTask = nil + } + } + } + } +} + +private enum EventFirmwareInstallerDependencies { + static func downloader() -> EventFirmwareArtifactDownloader { + #if DEBUG && targetEnvironment(simulator) + return EventFirmwareArtifactDownloader( + cacheDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent("EventFirmwareSimulator", isDirectory: true), + download: { url, _ in + guard let payload = EventFirmwareOTADebugFixture.payload(for: url) else { + throw URLError(.fileDoesNotExist) + } + let temporaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try payload.write(to: temporaryURL, options: .atomic) + return ( + temporaryURL, + URLResponse( + url: url, + mimeType: "application/octet-stream", + expectedContentLength: payload.count, + textEncodingName: nil + ) + ) + } + ) + #else + return EventFirmwareArtifactDownloader() + #endif + } +} + +private extension EventFirmwareOTAArtifact.Format { + var firmwareType: FirmwareFile.FirmwareType { + switch self { + case .bin: + return .bin + case .otaZip: + return .otaZip + } + } +} + +private struct SimulatorFirmwarePreview: Identifiable { + let id = UUID() + let title: String + let fileName: String + let byteCount: Int64 +} + +private struct SimulatorEventFirmwareProgressView: View { + let preview: SimulatorFirmwarePreview + + @Environment(\.dismiss) private var dismiss + @State private var progress = 0.0 + @State private var isComplete = false + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Spacer() + + if isComplete { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.green) + } else { + ProgressView() + .controlSize(.large) + .scaleEffect(1.4) + } + + VStack(spacing: 8) { + Text(isComplete ? "Mock Install Complete" : preview.title) + .font(.title2.bold()) + Text("Simulator Preview") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + + ProgressView(value: progress) + .progressViewStyle(.linear) + .padding(.horizontal, 32) + + VStack(spacing: 4) { + Text(preview.fileName) + .font(.footnote.monospaced()) + Text(ByteCountFormatter.string(fromByteCount: preview.byteCount, countStyle: .file)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if isComplete { + Button("Done") { + dismiss() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } else { + Text("No radio is contacted in this simulator preview.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding() + .navigationTitle("Firmware Install") + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(!isComplete) + .task { + for step in 1...40 { + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .milliseconds(75)) + progress = Double(step) / 40 + } + isComplete = true + } + } + } +} diff --git a/Meshtastic/Views/Settings/Firmware/Firmware.swift b/Meshtastic/Views/Settings/Firmware/Firmware.swift index 273097cf8..0e48b7cca 100644 --- a/Meshtastic/Views/Settings/Firmware/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware/Firmware.swift @@ -75,6 +75,7 @@ private struct FirmwareContentView: View { @EnvironmentObject var accessoryManager: AccessoryManager @EnvironmentObject var meshtasticAPI: MeshtasticAPI + @Query(sort: \EventFirmwareEntity.edition) private var eventFirmwareEditions: [EventFirmwareEntity] let node: NodeInfoEntity let hardware: DeviceHardwareEntity @@ -96,6 +97,7 @@ private struct FirmwareContentView: View { struct RowInstallation: Identifiable { let type: FirmwareFile.FirmwareType let url: URL + let expectedNodeNum: Int64 var id: String { "\(type.rawValue)-\(url.absoluteString)" } } @@ -177,6 +179,35 @@ private struct FirmwareContentView: View { // Extracted switch logic to keep body clean firmwareRows } + + if !relevantEventFirmwareEditions.isEmpty { + Section { + ForEach(relevantEventFirmwareEditions, id: \.edition) { event in + NavigationLink { + EventFirmwareInstallerView( + event: event, + node: node, + hardware: hardware + ) { type, url in + rowInstallation = RowInstallation( + type: type, + url: url, + expectedNodeNum: node.num + ) + } + } label: { + EventFirmwareInstallerRow( + event: event, + isInstalled: accessoryManager.firmwareEdition.editionKey == event.edition + ) + } + } + } header: { + Text("Going to an event?") + } footer: { + Text("Review event firmware for this connected device, or return an event device to standard Meshtastic firmware.") + } + } } .navigationTitle("Firmware Updates") .navigationBarTitleDisplayMode(.inline) @@ -188,14 +219,40 @@ private struct FirmwareContentView: View { .sheet(item: $rowInstallation) { installation in switch installation.type { case .otaZip: - NRFDFUSheet(firmwareToFlash: installation.url) + NRFDFUSheet( + firmwareToFlash: installation.url, + expectedNodeNum: installation.expectedNodeNum + ) case .uf2: UF2MassStorageView(fileURL: installation.url) case .bin: - ESP32OTAIntroSheet(binFileURL: installation.url) + ESP32OTAIntroSheet( + binFileURL: installation.url, + expectedNodeNum: installation.expectedNodeNum + ) } } } + + private var relevantEventFirmwareEditions: [EventFirmwareEntity] { + eventFirmwareEditions + .filter { + !$0.hasEnded() || accessoryManager.firmwareEdition.editionKey == $0.edition + } + .sorted { + switch ($0.eventStartDate, $1.eventStartDate) { + case let (lhs?, rhs?): + if lhs != rhs { return lhs < rhs } + case (.some, nil): + return true + case (nil, .some): + return false + case (nil, nil): + break + } + return ($0.displayName ?? $0.edition) < ($1.displayName ?? $1.edition) + } + } // MARK: - Subviews @@ -206,7 +263,11 @@ private struct FirmwareContentView: View { let stables = firmwareList.mostRecentFirmware(forReleaseType: .stable) ForEach(stables, id: \.localUrl) { release in FirmwareRow(firmwareFile: release) { type, url in - self.rowInstallation = RowInstallation(type: type, url: url) + self.rowInstallation = RowInstallation( + type: type, + url: url, + expectedNodeNum: node.num + ) } } if let last = stables.last, let notes = last.releaseNotes { @@ -218,7 +279,11 @@ private struct FirmwareContentView: View { let alphas = firmwareList.mostRecentFirmware(forReleaseType: .alpha) ForEach(alphas, id: \.localUrl) { release in FirmwareRow(firmwareFile: release) { type, url in - self.rowInstallation = RowInstallation(type: type, url: url) + self.rowInstallation = RowInstallation( + type: type, + url: url, + expectedNodeNum: node.num + ) } } if let last = alphas.last, let notes = last.releaseNotes { @@ -233,7 +298,11 @@ private struct FirmwareContentView: View { } else { ForEach(downloads, id: \.localUrl) { file in FirmwareRow(firmwareFile: file) { type, url in - self.rowInstallation = RowInstallation(type: type, url: url) + self.rowInstallation = RowInstallation( + type: type, + url: url, + expectedNodeNum: node.num + ) } } .onDelete { offsets in @@ -285,7 +354,7 @@ private struct FirmwareContentView: View { } var allowedTypes: [UTType] { - switch hardware.architecture.flatMap( {Architecture(rawValue: $0) }) { + switch hardware.architecture.flatMap({ Architecture(rawValue: $0) }) { case .esp32, .esp32C3, .esp32S3, .esp32C6: return [.BINFirmware] case .nrf52840: @@ -315,7 +384,7 @@ private struct FirmwareContentView: View { guard let selectedFile: URL = try result.get().first else { return } self.locallyChosenFirmwareFile = selectedFile - switch hardware.architecture.flatMap( {Architecture(rawValue: $0) }) { + switch hardware.architecture.flatMap({ Architecture(rawValue: $0) }) { case .esp32, .esp32C3, .esp32S3, .esp32C6: if selectedFile.pathExtension.lowercased() == "bin" { self.showInstallationSheet = .bin @@ -386,6 +455,37 @@ private struct FirmwareContentView: View { } } +private struct EventFirmwareInstallerRow: View { + let event: EventFirmwareEntity + let isInstalled: Bool + + var body: some View { + HStack(spacing: 12) { + EventFirmwareIcon( + edition: event.firmwareEdition ?? .vanilla, + iconURL: event.iconURL, + size: 36 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(event.displayName ?? event.firmwareEdition?.name ?? event.edition) + .font(.body) + + if isInstalled { + Text("Return to standard firmware") + .font(.caption) + .foregroundStyle(.secondary) + } else if let dateRange = event.formattedDateRange { + Text(dateRange) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 2) + } +} + // 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. diff --git a/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift b/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift index bf0418656..c89107f23 100644 --- a/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift @@ -15,6 +15,17 @@ struct NRFDFUSheet: View { @State private var showChirpyGame = false let firmwareToFlash: URL + var expectedNodeNum: Int64? + + private var isExpectedDeviceActive: Bool { + guard let expectedNodeNum else { + return accessoryManager.activeDeviceNum != nil + } + return EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: expectedNodeNum, + activeNodeNum: accessoryManager.activeDeviceNum + ) + } var body: some View { VStack { @@ -42,6 +53,7 @@ struct NRFDFUSheet: View { case .idle: Button("Begin Update") { Task { + guard isExpectedDeviceActive else { return } if let connection = accessoryManager.activeConnection?.connection as? BLEConnection { let peripheral = await connection.peripheral dfuViewModel.startDFU(peripheral: peripheral, zipFileUrl: firmwareToFlash) @@ -52,6 +64,7 @@ struct NRFDFUSheet: View { .frame(maxWidth: .infinity) .cornerRadius(10) .buttonStyle(.borderedProminent) + .disabled(!isExpectedDeviceActive) case .uploading, .starting, .success: Text(dfuViewModel.rotatingMessage) diff --git a/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift b/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift new file mode 100644 index 000000000..8d630547d --- /dev/null +++ b/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift @@ -0,0 +1,216 @@ +import CryptoKit +import Foundation +import Testing + +@testable import Meshtastic + +@Suite("Event firmware artifact downloader", .serialized) +struct EventFirmwareArtifactDownloaderTests { + + @Test func downloadsVerifiesAndCachesArtifact() async throws { + let fixture = try Fixture(payload: Data("verified firmware".utf8)) + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, _ in (fixture.downloadedFile, try fixture.response()) } + ) + + let localURL = try await downloader.prepare(fixture.artifact()) + + #expect(FileManager.default.fileExists(atPath: localURL.path)) + #expect(try Data(contentsOf: localURL) == fixture.payload) + #expect(localURL.pathExtension == "bin") + } + + @Test func reusesVerifiedCacheWithoutDownloadingAgain() async throws { + let fixture = try Fixture(payload: Data("cached firmware".utf8)) + let counter = DownloadCounter() + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, _ in + await counter.increment() + return (fixture.downloadedFile, try fixture.response()) + } + ) + + _ = try await downloader.prepare(fixture.artifact()) + _ = try await downloader.prepare(fixture.artifact()) + + #expect(await counter.value == 1) + } + + @Test func replacesInvalidCachedFile() async throws { + let fixture = try Fixture(payload: Data("replacement firmware".utf8)) + let artifact = fixture.artifact() + try FileManager.default.createDirectory( + at: fixture.cacheDirectory, + withIntermediateDirectories: true + ) + let cachedFile = fixture.cacheDirectory + .appendingPathComponent(artifact.sha256) + .appendingPathExtension("bin") + try Data("truncated".utf8).write(to: cachedFile) + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, _ in (fixture.downloadedFile, try fixture.response()) } + ) + + let localURL = try await downloader.prepare(artifact) + + #expect(localURL == cachedFile) + #expect(try Data(contentsOf: localURL) == fixture.payload) + } + + @Test func passesSignedByteCountAsDownloadCeiling() async throws { + let fixture = try Fixture(payload: Data("bounded firmware".utf8)) + let recorder = DownloadLimitRecorder() + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, maximumByteCount in + await recorder.record(maximumByteCount) + return (fixture.downloadedFile, try fixture.response()) + } + ) + + _ = try await downloader.prepare(fixture.artifact()) + + #expect(await recorder.value == Int64(fixture.payload.count)) + } + + @Test func rejectsByteCountMismatchAndRemovesStagingFile() async throws { + let fixture = try Fixture(payload: Data("short".utf8)) + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, _ in (fixture.downloadedFile, try fixture.response()) } + ) + let artifact = fixture.artifact(byteCount: Int64(fixture.payload.count + 1)) + + await #expect(throws: EventFirmwareArtifactDownloadError.byteCountMismatch) { + try await downloader.prepare(artifact) + } + #expect(try fixture.cachedFiles().isEmpty) + } + + @Test func rejectsChecksumMismatchAndRemovesStagingFile() async throws { + let fixture = try Fixture(payload: Data("wrong digest".utf8)) + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, _ in (fixture.downloadedFile, try fixture.response()) } + ) + let artifact = fixture.artifact(sha256: String(repeating: "0", count: 64)) + + await #expect(throws: EventFirmwareArtifactDownloadError.checksumMismatch) { + try await downloader.prepare(artifact) + } + #expect(try fixture.cachedFiles().isEmpty) + } + + @Test func rejectsRedirectedFinalURL() async throws { + let fixture = try Fixture(payload: Data("redirected".utf8)) + let redirectedURL = try #require(URL(string: "https://evil.example/firmware.bin")) + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, _ in (fixture.downloadedFile, try fixture.response(url: redirectedURL)) } + ) + + await #expect(throws: EventFirmwareArtifactDownloadError.unexpectedResponseURL) { + try await downloader.prepare(fixture.artifact()) + } + } + + @Test func rejectsNonSuccessHTTPStatus() async throws { + let fixture = try Fixture(payload: Data("not firmware".utf8)) + let downloader = EventFirmwareArtifactDownloader( + cacheDirectory: fixture.cacheDirectory, + download: { _, _ in (fixture.downloadedFile, try fixture.response(statusCode: 404)) } + ) + + await #expect(throws: EventFirmwareArtifactDownloadError.httpStatus(404)) { + try await downloader.prepare(fixture.artifact()) + } + } + + private actor DownloadCounter { + private(set) var value = 0 + func increment() { + value += 1 + } + } + + private actor DownloadLimitRecorder { + private(set) var value: Int64? + func record(_ value: Int64) { + self.value = value + } + } + + private struct Fixture { + let payload: Data + let remoteURL: URL + let rootDirectory: URL + let cacheDirectory: URL + let downloadedFile: URL + + init(payload: Data) throws { + self.payload = payload + remoteURL = try #require(URL( + string: "https://raw.githubusercontent.com/meshtastic/firmware/" + + "0123456789abcdef0123456789abcdef01234567/firmware.bin" + )) + rootDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + cacheDirectory = rootDirectory.appendingPathComponent("cache", isDirectory: true) + downloadedFile = rootDirectory.appendingPathComponent("download.tmp") + try FileManager.default.createDirectory( + at: rootDirectory, + withIntermediateDirectories: true + ) + try payload.write(to: downloadedFile) + } + + func artifact( + sha256: String? = nil, + byteCount: Int64? = nil + ) -> EventFirmwareOTAArtifact { + EventFirmwareOTAArtifact( + pioEnv: "tbeam-s3-core", + hwModel: 12, + architecture: Architecture.esp32S3.rawValue, + format: .bin, + url: remoteURL, + sha256: sha256 ?? SHA256.hash(data: payload).hexString, + byteCount: byteCount ?? Int64(payload.count), + minimumSourceVersion: "2.7.0", + partitionRole: "app0", + partitionScheme: "8MB", + dfuProtocol: nil, + minimumBootloaderVersion: nil + ) + } + + func response( + statusCode: Int = 200, + url: URL? = nil + ) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url ?? remoteURL, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/octet-stream"] + )) + } + + func cachedFiles() throws -> [URL] { + guard FileManager.default.fileExists(atPath: cacheDirectory.path) else { return [] } + return try FileManager.default.contentsOfDirectory( + at: cacheDirectory, + includingPropertiesForKeys: nil + ) + } + } +} + +private extension SHA256.Digest { + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/MeshtasticTests/EventFirmwareInstallerViewTests.swift b/MeshtasticTests/EventFirmwareInstallerViewTests.swift new file mode 100644 index 000000000..af1ae6e32 --- /dev/null +++ b/MeshtasticTests/EventFirmwareInstallerViewTests.swift @@ -0,0 +1,57 @@ +import Testing + +@testable import Meshtastic + +@Suite("Event firmware installer policy") +struct EventFirmwareInstallerViewTests { + + @Test func verifiedSelectionUsesInAppInstall() throws { + let contract = try EventFirmwareOTADebugFixture.verifiedContract() + let availability = EventFirmwareOTAAvailabilityResolver(contract: contract).availability( + edition: "DEFCON", + target: target(), + purpose: .event + ) + + #expect(EventFirmwareInstallerPolicy.primaryAction(for: availability) == .install) + } + + @Test func missingContractUsesWebFlasher() { + let availability = EventFirmwareOTAAvailability.unavailable(.contractUnavailable) + + #expect(EventFirmwareInstallerPolicy.primaryAction(for: availability) == .webFlasher) + } + + @Test func unsupportedOTAPathUsesWebFlasher() { + let availability = EventFirmwareOTAAvailability.unavailable(.unsupportedOTAPath) + + #expect(EventFirmwareInstallerPolicy.primaryAction(for: availability) == .webFlasher) + } + + @Test func installHandoffRequiresSameConnectedNode() { + #expect(EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: 123, + activeNodeNum: 123 + )) + #expect(!EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: 123, + activeNodeNum: 456 + )) + #expect(!EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: 123, + activeNodeNum: nil + )) + } + + private func target() -> EventFirmwareOTATarget { + EventFirmwareOTATarget( + pioEnv: "tbeam-s3-core", + hwModel: 12, + architecture: Architecture.esp32S3.rawValue, + firmwareVersion: "2.7.26.54e0d8d", + supportsOTA: true, + partitionScheme: "8MB", + bootloaderVersion: nil + ) + } +} diff --git a/MeshtasticTests/EventFirmwareOTAContractTests.swift b/MeshtasticTests/EventFirmwareOTAContractTests.swift new file mode 100644 index 000000000..ac3ac8a4e --- /dev/null +++ b/MeshtasticTests/EventFirmwareOTAContractTests.swift @@ -0,0 +1,180 @@ +import CryptoKit +import Foundation +import Testing + +@testable import Meshtastic + +@Suite("Event firmware OTA contract") +struct EventFirmwareOTAContractTests { + + private struct SignedFixture { + let keyId: String + let publicKey: Data + let envelopeData: Data + } + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func verifiesSignatureBeforeDecodingPayload() throws { + let fixture = try signedFixture() + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: [fixture.keyId: fixture.publicKey], + now: { now } + ) + + let contract = try verifier.verify(envelopeData: fixture.envelopeData) + + #expect(contract.schemaVersion == 1) + #expect(contract.releaseId == "defcon-34-b00d76f") + #expect(contract.edition == "DEFCON") + #expect(contract.version == "2.8.0.b00d76f") + } + + @Test func rejectsTamperedPayload() throws { + let fixture = try signedFixture() + var envelope = try JSONDecoder().decode(EventFirmwareOTAEnvelope.self, from: fixture.envelopeData) + var payload = try #require(Data(base64Encoded: envelope.payload)) + payload[payload.startIndex] ^= 0x01 + envelope.payload = payload.base64EncodedString() + let tamperedEnvelope = try JSONEncoder().encode(envelope) + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: [fixture.keyId: fixture.publicKey], + now: { now } + ) + + #expect(throws: EventFirmwareOTAContractError.invalidSignature) { + try verifier.verify(envelopeData: tamperedEnvelope) + } + } + + @Test func rejectsSignatureFromDifferentKey() throws { + let fixture = try signedFixture() + let otherKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data((32..<64).map(UInt8.init)) + ) + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: [fixture.keyId: otherKey.publicKey.rawRepresentation], + now: { now } + ) + + #expect(throws: EventFirmwareOTAContractError.invalidSignature) { + try verifier.verify(envelopeData: fixture.envelopeData) + } + } + + @Test func rejectsUnknownKeyIdentifier() throws { + let fixture = try signedFixture() + let verifier = EventFirmwareOTAContractVerifier(trustedKeys: [:], now: { now }) + + #expect(throws: EventFirmwareOTAContractError.unknownKey("event-fixture-1")) { + try verifier.verify(envelopeData: fixture.envelopeData) + } + } + + @Test func rejectsMalformedBase64BeforePayloadDecode() throws { + let envelope = EventFirmwareOTAEnvelope( + keyId: "event-fixture-1", + payload: "not base64!", + signature: "also not base64!" + ) + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: ["event-fixture-1": Data(repeating: 0, count: 32)], + now: { now } + ) + + #expect(throws: EventFirmwareOTAContractError.malformedEnvelope) { + try verifier.verify(envelopeData: JSONEncoder().encode(envelope)) + } + } + + @Test func rejectsExpiredContract() throws { + let fixture = try signedFixture(expiresAt: now.addingTimeInterval(-1)) + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: [fixture.keyId: fixture.publicKey], + now: { now } + ) + + #expect(throws: EventFirmwareOTAContractError.expired) { + try verifier.verify(envelopeData: fixture.envelopeData) + } + } + + @Test func rejectsContractIssuedTooFarInTheFuture() throws { + let fixture = try signedFixture( + issuedAt: now.addingTimeInterval(301) + ) + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: [fixture.keyId: fixture.publicKey], + now: { now } + ) + + #expect(throws: EventFirmwareOTAContractError.notYetValid) { + try verifier.verify(envelopeData: fixture.envelopeData) + } + } + + @Test func rejectsInvertedValidityWindow() throws { + let issuedAt = now.addingTimeInterval(-60) + let fixture = try signedFixture( + issuedAt: issuedAt, + expiresAt: issuedAt + ) + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: [fixture.keyId: fixture.publicKey], + now: { now } + ) + + #expect(throws: EventFirmwareOTAContractError.invalidValidityWindow) { + try verifier.verify(envelopeData: fixture.envelopeData) + } + } + + @Test func rejectsUnsupportedSchema() throws { + let fixture = try signedFixture(schemaVersion: 2) + let verifier = EventFirmwareOTAContractVerifier( + trustedKeys: [fixture.keyId: fixture.publicKey], + now: { now } + ) + + #expect(throws: EventFirmwareOTAContractError.unsupportedSchema(2)) { + try verifier.verify(envelopeData: fixture.envelopeData) + } + } + + private func signedFixture( + schemaVersion: Int = 1, + issuedAt: Date? = nil, + expiresAt: Date? = nil + ) throws -> SignedFixture { + let privateKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data((0..<32).map(UInt8.init)) + ) + let contract = EventFirmwareOTAContract( + schemaVersion: schemaVersion, + releaseId: "defcon-34-b00d76f", + edition: "DEFCON", + version: "2.8.0.b00d76f", + issuedAt: issuedAt ?? now.addingTimeInterval(-60), + expiresAt: expiresAt ?? now.addingTimeInterval(3_600), + artifacts: [], + standardArtifacts: [] + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + let payload = try encoder.encode(contract) + let signature = try privateKey.signature(for: payload) + let keyId = "event-fixture-1" + let envelope = EventFirmwareOTAEnvelope( + keyId: keyId, + payload: payload.base64EncodedString(), + signature: signature.base64EncodedString() + ) + + return SignedFixture( + keyId: keyId, + publicKey: privateKey.publicKey.rawRepresentation, + envelopeData: try JSONEncoder().encode(envelope) + ) + } +} diff --git a/MeshtasticTests/EventFirmwareOTASelectorTests.swift b/MeshtasticTests/EventFirmwareOTASelectorTests.swift new file mode 100644 index 000000000..fbbb55b0e --- /dev/null +++ b/MeshtasticTests/EventFirmwareOTASelectorTests.swift @@ -0,0 +1,293 @@ +import Foundation +import Testing + +@testable import Meshtastic + +@Suite("Event firmware OTA selector") +struct EventFirmwareOTASelectorTests { + + @Test func selectsExactESP32Target() throws { + let expected = try artifact() + let result = try EventFirmwareOTASelector().select( + from: contract(artifacts: [expected]), + for: target(), + purpose: .event + ) + + #expect(result.artifact == expected) + #expect(result.purpose == .event) + } + + @Test func requiresExactPlatformIOEnvironmentAndHardwareModel() throws { + let wrongEnvironment = try artifact(pioEnv: "t-deck") + let wrongHardware = try artifact(hwModel: 99) + + #expect(throws: EventFirmwareOTASelectionError.noExactTarget) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [wrongEnvironment, wrongHardware]), + for: target(), + purpose: .event + ) + } + } + + @Test func requiresExactArchitecture() throws { + let wrongArchitecture = try artifact(architecture: Architecture.nrf52840.rawValue) + + #expect(throws: EventFirmwareOTASelectionError.noExactTarget) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [wrongArchitecture]), + for: target(), + purpose: .event + ) + } + } + + @Test func rejectsDuplicateExactTargets() throws { + let first = try artifact() + let second = try artifact( + urlString: "https://raw.githubusercontent.com/meshtastic/firmware/" + + "0123456789abcdef0123456789abcdef01234567/duplicate.bin" + ) + + #expect(throws: EventFirmwareOTASelectionError.ambiguousTarget) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [first, second]), + for: target(), + purpose: .event + ) + } + } + + @Test func rejectsSourceFirmwareBelowMinimum() throws { + let requiresNewer = try artifact(minimumSourceVersion: "2.8.0") + + #expect(throws: EventFirmwareOTASelectionError.sourceFirmwareTooOld(minimum: "2.8.0")) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [requiresNewer]), + for: target(firmwareVersion: "2.7.26.54e0d8d"), + purpose: .event + ) + } + } + + @Test func rejectsMalformedSourceFirmwareVersion() throws { + #expect(throws: EventFirmwareOTASelectionError.sourceFirmwareTooOld(minimum: "2.7.0")) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [try artifact()]), + for: target(firmwareVersion: "2.7.invalid"), + purpose: .event + ) + } + } + + @Test func rejectsOverflowingSourceFirmwareComponent() throws { + let artifact = try artifact(minimumSourceVersion: "2.0.0") + let overflow = "999999999999999999999999999999999999999999999999" + + #expect(throws: EventFirmwareOTASelectionError.sourceFirmwareTooOld(minimum: "2.0.0")) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [artifact]), + for: target(firmwareVersion: "2.\(overflow).0"), + purpose: .event + ) + } + } + + @Test func rejectsTargetWithoutAppSupportedOTAPath() throws { + #expect(throws: EventFirmwareOTASelectionError.unsupportedOTAPath) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [try artifact()]), + for: target(supportsOTA: false), + purpose: .event + ) + } + } + + @Test func rejectsMutableRawGitHubURL() throws { + let mutable = try artifact( + urlString: "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/event/defcon.bin" + ) + + #expect(throws: EventFirmwareOTASelectionError.unapprovedArtifactURL) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [mutable]), + for: target(), + purpose: .event + ) + } + } + + @Test func requiresESP32AppPartitionPayload() throws { + let helperImage = try artifact(partitionRole: "app1") + + #expect(throws: EventFirmwareOTASelectionError.incompatibleArtifact) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [helperImage]), + for: target(), + purpose: .event + ) + } + } + + @Test func requiresExactESP32PartitionScheme() throws { + let wrongPartitionScheme = try artifact(partitionScheme: "4MB") + + #expect(throws: EventFirmwareOTASelectionError.incompatibleArtifact) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [wrongPartitionScheme]), + for: target(), + purpose: .event + ) + } + } + + @Test func rejectsOversizedArtifact() throws { + let oversized = try artifact(byteCount: 16 * 1_024 * 1_024 + 1) + + #expect(throws: EventFirmwareOTASelectionError.incompatibleArtifact) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [oversized]), + for: target(), + purpose: .event + ) + } + } + + @Test func validatesNRFProtocolAndBootloaderMinimum() throws { + let nrfArtifact = try artifact( + pioEnv: "t-echo", + hwModel: 8, + architecture: Architecture.nrf52840.rawValue, + format: .otaZip, + partitionRole: nil, + partitionScheme: nil, + dfuProtocol: "nordic-legacy", + minimumBootloaderVersion: "0.6.1" + ) + let oldBootloader = EventFirmwareOTATarget( + pioEnv: "t-echo", + hwModel: 8, + architecture: Architecture.nrf52840.rawValue, + firmwareVersion: "2.7.26.54e0d8d", + supportsOTA: true, + partitionScheme: nil, + bootloaderVersion: "0.6.0" + ) + + #expect(throws: EventFirmwareOTASelectionError.bootloaderTooOld(minimum: "0.6.1")) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [nrfArtifact]), + for: oldBootloader, + purpose: .event + ) + } + } + + @Test func requiresExplicitNRFBootloaderMinimum() throws { + let nrfArtifact = try artifact( + pioEnv: "t-echo", + hwModel: 7, + architecture: Architecture.nrf52840.rawValue, + format: .otaZip, + partitionRole: nil, + partitionScheme: nil, + dfuProtocol: "nordic-legacy", + minimumBootloaderVersion: nil + ) + let nrfTarget = EventFirmwareOTATarget( + pioEnv: "t-echo", + hwModel: 7, + architecture: Architecture.nrf52840.rawValue, + firmwareVersion: "2.7.26.54e0d8d", + supportsOTA: true, + partitionScheme: nil, + bootloaderVersion: "0.6.1" + ) + + #expect(throws: EventFirmwareOTASelectionError.incompatibleArtifact) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [nrfArtifact]), + for: nrfTarget, + purpose: .event + ) + } + } + + @Test func returnToStandardUsesOnlyStandardArtifacts() throws { + let eventArtifact = try artifact() + let standardArtifact = try artifact( + urlString: "https://raw.githubusercontent.com/meshtastic/firmware/0123456789abcdef0123456789abcdef01234567/standard.bin" + ) + let result = try EventFirmwareOTASelector().select( + from: contract(artifacts: [eventArtifact], standardArtifacts: [standardArtifact]), + for: target(), + purpose: .standard + ) + + #expect(result.artifact == standardArtifact) + #expect(result.purpose == .standard) + } + + private func target( + firmwareVersion: String = "2.7.26.54e0d8d", + supportsOTA: Bool = true + ) -> EventFirmwareOTATarget { + EventFirmwareOTATarget( + pioEnv: "tbeam-s3-core", + hwModel: 12, + architecture: Architecture.esp32S3.rawValue, + firmwareVersion: firmwareVersion, + supportsOTA: supportsOTA, + partitionScheme: "8MB", + bootloaderVersion: nil + ) + } + + private func contract( + artifacts: [EventFirmwareOTAArtifact], + standardArtifacts: [EventFirmwareOTAArtifact] = [] + ) -> EventFirmwareOTAContract { + EventFirmwareOTAContract( + schemaVersion: 1, + releaseId: "defcon-34-b00d76f", + edition: "DEFCON", + version: "2.8.0.b00d76f", + issuedAt: .distantPast, + expiresAt: .distantFuture, + artifacts: artifacts, + standardArtifacts: standardArtifacts + ) + } + + private func artifact( + pioEnv: String = "tbeam-s3-core", + hwModel: Int = 12, + architecture: String = Architecture.esp32S3.rawValue, + format: EventFirmwareOTAArtifact.Format = .bin, + urlString: String = + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/" + + "0123456789abcdef0123456789abcdef01234567/event/defcon.bin", + minimumSourceVersion: String = "2.7.0", + partitionRole: String? = "app0", + partitionScheme: String? = "8MB", + dfuProtocol: String? = nil, + minimumBootloaderVersion: String? = nil, + byteCount: Int64 = 1_024 + ) throws -> EventFirmwareOTAArtifact { + EventFirmwareOTAArtifact( + pioEnv: pioEnv, + hwModel: hwModel, + architecture: architecture, + format: format, + url: try #require(URL(string: urlString)), + sha256: String(repeating: "a", count: 64), + byteCount: byteCount, + minimumSourceVersion: minimumSourceVersion, + partitionRole: partitionRole, + partitionScheme: partitionScheme, + dfuProtocol: dfuProtocol, + minimumBootloaderVersion: minimumBootloaderVersion + ) + } +} diff --git a/MeshtasticTests/EventFirmwareOTAServiceTests.swift b/MeshtasticTests/EventFirmwareOTAServiceTests.swift new file mode 100644 index 000000000..9ecd9796e --- /dev/null +++ b/MeshtasticTests/EventFirmwareOTAServiceTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import Meshtastic + +@Suite("Event firmware OTA availability") +struct EventFirmwareOTAServiceTests { + + @Test func productionDisplayMetadataCannotAuthorizeInstall() { + let displayMetadata = EventFirmwareEntity(edition: "DEFCON") + displayMetadata.firmwareVersion = "2.8.0.b00d76f" + displayMetadata.firmwareId = "mutable-display-only-id" + let resolver = EventFirmwareOTAAvailabilityResolver(contract: nil) + + let availability = resolver.availability( + edition: displayMetadata.edition, + target: target(), + purpose: .event + ) + + #expect(availability == .unavailable(.contractUnavailable)) + } + + @Test func verifiedContractAuthorizesExactTarget() throws { + let contract = try EventFirmwareOTADebugFixture.verifiedContract() + let resolver = EventFirmwareOTAAvailabilityResolver(contract: contract) + + let availability = resolver.availability( + edition: "DEFCON", + target: target(), + purpose: .event + ) + + guard case let .available(selection) = availability else { + Issue.record("Expected the signed DEBUG fixture to authorize its exact target") + return + } + #expect(selection.artifact.format == .bin) + #expect(selection.purpose == .event) + } + + @Test func contractForAnotherEditionCannotAuthorizeInstall() throws { + let resolver = EventFirmwareOTAAvailabilityResolver( + contract: try EventFirmwareOTADebugFixture.verifiedContract() + ) + + let availability = resolver.availability( + edition: "BURNING_MAN", + target: target(), + purpose: .event + ) + + #expect(availability == .unavailable(.contractEditionMismatch)) + } + + @Test func returnToStandardUsesSignedStandardArtifact() throws { + let resolver = EventFirmwareOTAAvailabilityResolver( + contract: try EventFirmwareOTADebugFixture.verifiedContract() + ) + + let availability = resolver.availability( + edition: "DEFCON", + target: target(), + purpose: .standard + ) + + guard case let .available(selection) = availability else { + Issue.record("Expected a signed return-to-standard artifact") + return + } + #expect(selection.purpose == .standard) + #expect(selection.artifact.url.lastPathComponent == "standard.bin") + } + + private func target() -> EventFirmwareOTATarget { + EventFirmwareOTATarget( + pioEnv: "tbeam-s3-core", + hwModel: 12, + architecture: Architecture.esp32S3.rawValue, + firmwareVersion: "2.7.26.54e0d8d", + supportsOTA: true, + partitionScheme: "8MB", + bootloaderVersion: nil + ) + } +} From 181e38efe3dc81330667bd80140d46bb5df72c25 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:33:21 -0700 Subject: [PATCH 2/4] feat(firmware): align event OTA artifact contract --- Meshtastic/Model/EventFirmwareOTAContract.swift | 1 + Meshtastic/Model/EventFirmwareOTASelector.swift | 1 + Meshtastic/Model/EventFirmwareOTAService.swift | 1 + .../Firmware/EventFirmwareInstallerView.swift | 2 ++ .../EventFirmwareArtifactDownloaderTests.swift | 1 + .../EventFirmwareOTASelectorTests.swift | 14 ++++++++++++++ 6 files changed, 20 insertions(+) diff --git a/Meshtastic/Model/EventFirmwareOTAContract.swift b/Meshtastic/Model/EventFirmwareOTAContract.swift index 86d065003..c4a41c059 100644 --- a/Meshtastic/Model/EventFirmwareOTAContract.swift +++ b/Meshtastic/Model/EventFirmwareOTAContract.swift @@ -27,6 +27,7 @@ struct EventFirmwareOTAArtifact: Codable, Equatable, Sendable { let pioEnv: String let hwModel: Int let architecture: String + let version: String let format: Format let url: URL let sha256: String diff --git a/Meshtastic/Model/EventFirmwareOTASelector.swift b/Meshtastic/Model/EventFirmwareOTASelector.swift index 600c02a4b..b399c2f48 100644 --- a/Meshtastic/Model/EventFirmwareOTASelector.swift +++ b/Meshtastic/Model/EventFirmwareOTASelector.swift @@ -66,6 +66,7 @@ struct EventFirmwareOTASelector { throw EventFirmwareOTASelectionError.unapprovedArtifactURL } guard artifact.byteCount > 0, + numericVersion(artifact.version) != nil, artifact.sha256.count == 64, artifact.sha256.allSatisfy(\.isHexDigit) else { throw EventFirmwareOTASelectionError.incompatibleArtifact diff --git a/Meshtastic/Model/EventFirmwareOTAService.swift b/Meshtastic/Model/EventFirmwareOTAService.swift index 77ac633cd..8cfb24882 100644 --- a/Meshtastic/Model/EventFirmwareOTAService.swift +++ b/Meshtastic/Model/EventFirmwareOTAService.swift @@ -143,6 +143,7 @@ enum EventFirmwareOTADebugFixture { pioEnv: "tbeam-s3-core", hwModel: 12, architecture: Architecture.esp32S3.rawValue, + version: "2.8.0.b00d76f", format: .bin, url: url, sha256: SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined(), diff --git a/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift index d0bc5c71d..946c664b3 100644 --- a/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift +++ b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift @@ -104,6 +104,8 @@ struct EventFirmwareInstallerView: View { switch availability { case let .available(selection): + LabeledContent("Install version", value: selection.artifact.version) + .accessibilityElement(children: .combine) Label( selection.purpose == .event ? "Signed event firmware is available for this exact device target." diff --git a/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift b/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift index 8d630547d..771424198 100644 --- a/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift +++ b/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift @@ -175,6 +175,7 @@ struct EventFirmwareArtifactDownloaderTests { pioEnv: "tbeam-s3-core", hwModel: 12, architecture: Architecture.esp32S3.rawValue, + version: "2.8.0.b00d76f", format: .bin, url: remoteURL, sha256: sha256 ?? SHA256.hash(data: payload).hexString, diff --git a/MeshtasticTests/EventFirmwareOTASelectorTests.swift b/MeshtasticTests/EventFirmwareOTASelectorTests.swift index fbbb55b0e..051a369ae 100644 --- a/MeshtasticTests/EventFirmwareOTASelectorTests.swift +++ b/MeshtasticTests/EventFirmwareOTASelectorTests.swift @@ -154,6 +154,18 @@ struct EventFirmwareOTASelectorTests { } } + @Test func rejectsMalformedArtifactVersion() throws { + let malformed = try artifact(version: "latest") + + #expect(throws: EventFirmwareOTASelectionError.incompatibleArtifact) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [malformed]), + for: target(), + purpose: .event + ) + } + } + @Test func validatesNRFProtocolAndBootloaderMinimum() throws { let nrfArtifact = try artifact( pioEnv: "t-echo", @@ -264,6 +276,7 @@ struct EventFirmwareOTASelectorTests { pioEnv: String = "tbeam-s3-core", hwModel: Int = 12, architecture: String = Architecture.esp32S3.rawValue, + version: String = "2.8.0.b00d76f", format: EventFirmwareOTAArtifact.Format = .bin, urlString: String = "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/" + @@ -279,6 +292,7 @@ struct EventFirmwareOTASelectorTests { pioEnv: pioEnv, hwModel: hwModel, architecture: architecture, + version: version, format: format, url: try #require(URL(string: urlString)), sha256: String(repeating: "a", count: 64), From e68fbea80a9563aedf230a9ea3cef68e2d82631b Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:41:25 -0700 Subject: [PATCH 3/4] fix(firmware): revalidate event OTA handoff --- .../Model/EventFirmwareOTASelector.swift | 2 +- .../Firmware/EventFirmwareInstallerView.swift | 11 ++++++++++ .../EventFirmwareInstallerViewTests.swift | 20 +++++++++++++++++++ .../EventFirmwareOTASelectorTests.swift | 12 +++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Meshtastic/Model/EventFirmwareOTASelector.swift b/Meshtastic/Model/EventFirmwareOTASelector.swift index b399c2f48..d5718d69e 100644 --- a/Meshtastic/Model/EventFirmwareOTASelector.swift +++ b/Meshtastic/Model/EventFirmwareOTASelector.swift @@ -150,7 +150,7 @@ struct EventFirmwareOTASelector { .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingPrefix("v") let components = normalized.split(separator: ".", omittingEmptySubsequences: false) - guard components.count >= 3 else { return nil } + guard components.count == 3 || components.count == 4 else { return nil } let core = components.prefix(3) guard core.allSatisfy({ !$0.isEmpty && $0.allSatisfy { $0.isASCII && $0.isNumber } diff --git a/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift index 946c664b3..01035975c 100644 --- a/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift +++ b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift @@ -24,6 +24,13 @@ enum EventFirmwareInstallerPolicy { ) -> Bool { activeNodeNum == expectedNodeNum } + + static func isPreparedSelectionCurrent( + _ preparedSelection: EventFirmwareOTASelection, + availability: EventFirmwareOTAAvailability + ) -> Bool { + availability == .available(preparedSelection) + } } struct EventFirmwareInstallerView: View { @@ -275,6 +282,10 @@ struct EventFirmwareInstallerView: View { guard EventFirmwareInstallerPolicy.isExpectedDeviceActive( expectedNodeNum: expectedNodeNum, activeNodeNum: accessoryManager.activeDeviceNum + ), + EventFirmwareInstallerPolicy.isPreparedSelectionCurrent( + selection, + availability: availability ) else { preparationState = .failed( "The connected device changed. Select the event again for the current device." diff --git a/MeshtasticTests/EventFirmwareInstallerViewTests.swift b/MeshtasticTests/EventFirmwareInstallerViewTests.swift index af1ae6e32..9e5d83051 100644 --- a/MeshtasticTests/EventFirmwareInstallerViewTests.swift +++ b/MeshtasticTests/EventFirmwareInstallerViewTests.swift @@ -43,6 +43,20 @@ struct EventFirmwareInstallerViewTests { )) } + @Test func installHandoffRequiresSameExactSelection() throws { + let contract = try EventFirmwareOTADebugFixture.verifiedContract() + let prepared = try #require(contract.artifacts.first).selection + + #expect(EventFirmwareInstallerPolicy.isPreparedSelectionCurrent( + prepared, + availability: .available(prepared) + )) + #expect(!EventFirmwareInstallerPolicy.isPreparedSelectionCurrent( + prepared, + availability: .unavailable(.noCompatibleArtifact) + )) + } + private func target() -> EventFirmwareOTATarget { EventFirmwareOTATarget( pioEnv: "tbeam-s3-core", @@ -55,3 +69,9 @@ struct EventFirmwareInstallerViewTests { ) } } + +private extension EventFirmwareOTAArtifact { + var selection: EventFirmwareOTASelection { + EventFirmwareOTASelection(artifact: self, purpose: .event) + } +} diff --git a/MeshtasticTests/EventFirmwareOTASelectorTests.swift b/MeshtasticTests/EventFirmwareOTASelectorTests.swift index 051a369ae..058eddeab 100644 --- a/MeshtasticTests/EventFirmwareOTASelectorTests.swift +++ b/MeshtasticTests/EventFirmwareOTASelectorTests.swift @@ -166,6 +166,18 @@ struct EventFirmwareOTASelectorTests { } } + @Test func rejectsArtifactVersionWithExtraSuffixComponents() throws { + let malformed = try artifact(version: "2.8.0.b00d76f.extra") + + #expect(throws: EventFirmwareOTASelectionError.incompatibleArtifact) { + try EventFirmwareOTASelector().select( + from: contract(artifacts: [malformed]), + for: target(), + purpose: .event + ) + } + } + @Test func validatesNRFProtocolAndBootloaderMinimum() throws { let nrfArtifact = try artifact( pioEnv: "t-echo", From f4363a65ba87181179883508b07370aa843ed6b7 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:44:58 -0700 Subject: [PATCH 4/4] fix(firmware): address event installer review --- Localizable.xcstrings | 46 ++++++++++- Meshtastic/API/MeshtasticAPI.swift | 36 +++++---- .../Contents.json | 3 +- .../EventFirmwareArtifactDownloader.swift | 5 +- .../Model/EventFirmwareOTAContract.swift | 2 + .../Model/EventFirmwareOTASelector.swift | 2 + .../Model/EventFirmwareOTAService.swift | 2 + .../Views/Connect/EventFirmwareInfoView.swift | 24 +++--- .../ESP32 OTA/ESP32OTAIntroSheet.swift | 3 - .../Firmware/EventFirmwareInstallerView.swift | 55 +++++++++---- .../Views/Settings/Firmware/Firmware.swift | 10 ++- .../Firmware/NRF DFU/NRFDFUSheet.swift | 3 - ...EventFirmwareArtifactDownloaderTests.swift | 2 + .../EventFirmwareInstallerViewTests.swift | 17 ++++ .../EventFirmwareMetadataTests.swift | 78 +++++++++++++++++++ .../EventFirmwareOTAContractTests.swift | 2 + .../EventFirmwareOTASelectorTests.swift | 2 + .../EventFirmwareOTAServiceTests.swift | 2 + 18 files changed, 239 insertions(+), 55 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 62726645b..a141210cd 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -113610,7 +113610,49 @@ "Your radio is rebooting to save the imported settings — reconnect to verify.": {}, "channel keys (PSKs)": {}, "sensitive credentials": {}, - "your node's private key & admin keys": {} + "your node's private key & admin keys": {}, + "In-app installation requires bootloader %@ or newer.": { + "comment": "Event firmware installer requirement. The argument is the minimum bootloader version." + }, + "In-app installation requires device firmware %@ or newer.": { + "comment": "Event firmware installer requirement. The argument is the minimum source firmware version." + }, + "Install Event Firmware": { + "comment": "Primary action for installing an event firmware edition." + }, + "No package is published for this exact device target.": { + "comment": "Event firmware installer unavailable reason." + }, + "No trusted installation contract is currently published for this event.": { + "comment": "Event firmware installer unavailable reason." + }, + "Reconnect to this device before preparing its firmware package.": { + "comment": "Event firmware installer error shown when its target device is disconnected." + }, + "Return to Standard Firmware": { + "comment": "Primary action for replacing an event firmware edition with standard firmware." + }, + "The available package does not meet the app's download trust policy.": { + "comment": "Event firmware installer unavailable reason." + }, + "The connected device changed. Select the event again for the current device.": { + "comment": "Event firmware installer error shown when the connected target changes during preparation." + }, + "The download is verified before the existing firmware installer is opened. Keep the app open and your device nearby during installation.": { + "comment": "Footer explaining the verified event firmware installation flow." + }, + "The firmware package could not be downloaded and verified.": { + "comment": "Generic event firmware package preparation error." + }, + "The published installation contract is for a different event.": { + "comment": "Event firmware installer unavailable reason." + }, + "This app cannot verify a compatible in-app package for this exact device. The web flasher provides the supported installation path.": { + "comment": "Footer explaining why the event firmware installer falls back to the web flasher." + }, + "This device does not have an app-supported event firmware OTA path.": { + "comment": "Event firmware installer unavailable reason." + } }, "version": "1.1" -} \ No newline at end of file +} diff --git a/Meshtastic/API/MeshtasticAPI.swift b/Meshtastic/API/MeshtasticAPI.swift index f359c3b29..669e76c6f 100644 --- a/Meshtastic/API/MeshtasticAPI.swift +++ b/Meshtastic/API/MeshtasticAPI.swift @@ -1070,13 +1070,13 @@ extension MeshtasticAPI { context.insert(entity) } - if let value = payload.displayName { entity.displayName = value } - if let value = payload.welcomeMessage { entity.welcomeMessage = value } - if let value = payload.tag { entity.tag = value } - if let value = payload.eventStart { entity.eventStart = value } - if let value = payload.eventEnd { entity.eventEnd = value } - if let value = payload.timeZone { entity.timeZone = value } - if let value = payload.location { entity.location = value } + if let value = payload.displayName, !value.isEmpty { entity.displayName = value } + if let value = payload.welcomeMessage, !value.isEmpty { entity.welcomeMessage = value } + if let value = payload.tag, !value.isEmpty { entity.tag = value } + if let value = payload.eventStart, !value.isEmpty { entity.eventStart = value } + if let value = payload.eventEnd, !value.isEmpty { entity.eventEnd = value } + if let value = payload.timeZone, !value.isEmpty { entity.timeZone = value } + if let value = payload.location, !value.isEmpty { entity.location = value } if let value = EventFirmwareURLPolicy.httpsURL(from: payload.iconUrl)?.absoluteString { entity.iconUrl = value } @@ -1084,7 +1084,7 @@ extension MeshtasticAPI { EventFirmwareEntity.color(fromHex: value) != nil { entity.accentColor = value } - if let value = payload.domain { entity.domain = value } + if let value = payload.domain, !value.isEmpty { entity.domain = value } if let links = payload.links { let safeLinks = links.map { EventFirmwareEntity.Link(label: $0.label, url: $0.url) @@ -1095,8 +1095,8 @@ extension MeshtasticAPI { entity.setLinks(safeLinks) } } - if let value = payload.theme?.name { entity.themeName = value } - if let value = payload.theme?.tagline { entity.themeTagline = value } + if let value = payload.theme?.name, !value.isEmpty { entity.themeName = value } + if let value = payload.theme?.tagline, !value.isEmpty { entity.themeTagline = value } if let value = payload.theme?.colors?.primary, EventFirmwareEntity.color(fromHex: value) != nil { entity.themePrimaryColor = value @@ -1117,13 +1117,15 @@ extension MeshtasticAPI { entity.themePalette = validPalette } } - if let value = payload.theme?.fonts?.heading { entity.themeFontHeading = value } - if let value = payload.theme?.fonts?.body { entity.themeFontBody = value } - if let value = payload.firmware?.slug { entity.firmwareSlug = value } - if let value = payload.firmware?.version { entity.firmwareVersion = value } - if let value = payload.firmware?.id { entity.firmwareId = value } - if let value = payload.firmware?.title { entity.firmwareTitle = value } - if let value = payload.firmware?.releaseNotes { entity.firmwareReleaseNotes = value } + if let value = payload.theme?.fonts?.heading, !value.isEmpty { entity.themeFontHeading = value } + if let value = payload.theme?.fonts?.body, !value.isEmpty { entity.themeFontBody = value } + if let value = payload.firmware?.slug, !value.isEmpty { entity.firmwareSlug = value } + if let value = payload.firmware?.version, !value.isEmpty { entity.firmwareVersion = value } + if let value = payload.firmware?.id, !value.isEmpty { entity.firmwareId = value } + if let value = payload.firmware?.title, !value.isEmpty { entity.firmwareTitle = value } + if let value = payload.firmware?.releaseNotes, !value.isEmpty { + entity.firmwareReleaseNotes = value + } } try? context.save() diff --git a/Meshtastic/Assets.xcassets/EventFirmwareHAMVENTION.imageset/Contents.json b/Meshtastic/Assets.xcassets/EventFirmwareHAMVENTION.imageset/Contents.json index a3fb8ec77..19638fdfc 100644 --- a/Meshtastic/Assets.xcassets/EventFirmwareHAMVENTION.imageset/Contents.json +++ b/Meshtastic/Assets.xcassets/EventFirmwareHAMVENTION.imageset/Contents.json @@ -2,8 +2,7 @@ "images" : [ { "filename" : "hamvention.png", - "idiom" : "universal", - "scale" : "1x" + "idiom" : "universal" } ], "info" : { diff --git a/Meshtastic/Model/EventFirmwareArtifactDownloader.swift b/Meshtastic/Model/EventFirmwareArtifactDownloader.swift index 233e1570a..b85926dd8 100644 --- a/Meshtastic/Model/EventFirmwareArtifactDownloader.swift +++ b/Meshtastic/Model/EventFirmwareArtifactDownloader.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareArtifactDownloader.swift + import CryptoKit import Foundation @@ -120,7 +122,8 @@ actor EventFirmwareArtifactDownloader { } } -private final class EventFirmwareBoundedDownloadDelegate: NSObject, +/// Cancels a URL session download task as soon as its streamed byte count exceeds a trusted limit. +final class EventFirmwareBoundedDownloadDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { diff --git a/Meshtastic/Model/EventFirmwareOTAContract.swift b/Meshtastic/Model/EventFirmwareOTAContract.swift index c4a41c059..070c9b813 100644 --- a/Meshtastic/Model/EventFirmwareOTAContract.swift +++ b/Meshtastic/Model/EventFirmwareOTAContract.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareOTAContract.swift + import CryptoKit import Foundation diff --git a/Meshtastic/Model/EventFirmwareOTASelector.swift b/Meshtastic/Model/EventFirmwareOTASelector.swift index d5718d69e..bf06b35ce 100644 --- a/Meshtastic/Model/EventFirmwareOTASelector.swift +++ b/Meshtastic/Model/EventFirmwareOTASelector.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareOTASelector.swift + import Foundation struct EventFirmwareOTATarget: Equatable, Sendable { diff --git a/Meshtastic/Model/EventFirmwareOTAService.swift b/Meshtastic/Model/EventFirmwareOTAService.swift index 8cfb24882..3a50e013a 100644 --- a/Meshtastic/Model/EventFirmwareOTAService.swift +++ b/Meshtastic/Model/EventFirmwareOTAService.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareOTAService.swift + import CryptoKit import Foundation diff --git a/Meshtastic/Views/Connect/EventFirmwareInfoView.swift b/Meshtastic/Views/Connect/EventFirmwareInfoView.swift index c5a07b007..e2b147fcc 100644 --- a/Meshtastic/Views/Connect/EventFirmwareInfoView.swift +++ b/Meshtastic/Views/Connect/EventFirmwareInfoView.swift @@ -268,23 +268,23 @@ private enum EventFirmwareIconLoader { var request = URLRequest(url: url) request.timeoutInterval = 15 do { - let (bytes, response) = try await URLSession.shared.bytes(for: request) + let maximumBytes = Int64(EventFirmwareImageValidator.maximumEncodedBytes) + let delegate = EventFirmwareBoundedDownloadDelegate(maximumByteCount: maximumBytes) + let (fileURL, response) = try await URLSession.shared.download( + for: request, + delegate: delegate + ) guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), - response.expectedContentLength <= Int64(EventFirmwareImageValidator.maximumEncodedBytes), + response.expectedContentLength <= maximumBytes, + !delegate.exceededMaximumByteCount, + EventFirmwareURLPolicy.httpsURL(from: response.url?.absoluteString) != nil, response.mimeType == "image/png" || response.mimeType == "image/jpeg" else { return nil } - var data = Data() - if response.expectedContentLength > 0 { - data.reserveCapacity(Int(response.expectedContentLength)) - } - for try await byte in bytes { - guard data.count < EventFirmwareImageValidator.maximumEncodedBytes else { - return nil - } - data.append(byte) - } + let fileSize = try fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize + guard let fileSize, fileSize <= EventFirmwareImageValidator.maximumEncodedBytes else { return nil } + let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) return EventFirmwareImageValidator.image(from: data) } catch { return nil diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift index 391526448..5c7db11f7 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift @@ -36,9 +36,6 @@ struct ESP32OTAIntroSheet: View { } private var isExpectedDeviceActive: Bool { - guard let expectedNodeNum else { - return accessoryManager.activeDeviceNum != nil - } return EventFirmwareInstallerPolicy.isExpectedDeviceActive( expectedNodeNum: expectedNodeNum, activeNodeNum: accessoryManager.activeDeviceNum diff --git a/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift index 01035975c..e977673c0 100644 --- a/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift +++ b/Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift @@ -1,6 +1,11 @@ +// MARK: EventFirmwareInstallerView.swift + import Foundation +import OSLog import SwiftUI +// MARK: - Policy + enum EventFirmwareInstallerPrimaryAction: Equatable { case install case webFlasher @@ -25,6 +30,17 @@ enum EventFirmwareInstallerPolicy { activeNodeNum == expectedNodeNum } + static func isExpectedDeviceActive( + expectedNodeNum: Int64?, + activeNodeNum: Int64? + ) -> Bool { + guard let expectedNodeNum else { return activeNodeNum != nil } + return isExpectedDeviceActive( + expectedNodeNum: expectedNodeNum, + activeNodeNum: activeNodeNum + ) + } + static func isPreparedSelectionCurrent( _ preparedSelection: EventFirmwareOTASelection, availability: EventFirmwareOTAAvailability @@ -33,6 +49,8 @@ enum EventFirmwareInstallerPolicy { } } +// MARK: - Installer View + struct EventFirmwareInstallerView: View { typealias Install = (FirmwareFile.FirmwareType, URL) -> Void @@ -209,7 +227,9 @@ struct EventFirmwareInstallerView: View { } private var actionTitle: String { - installPurpose == .event ? "Install Event Firmware" : "Return to Standard Firmware" + installPurpose == .event + ? String(localized: "Install Event Firmware") + : String(localized: "Return to Standard Firmware") } private var actionIcon: String { @@ -219,9 +239,9 @@ struct EventFirmwareInstallerView: View { private var actionFooter: String { switch availability { case .available: - return "The download is verified before the existing firmware installer is opened. Keep the app open and your device nearby during installation." + return String(localized: "The download is verified before the existing firmware installer is opened. Keep the app open and your device nearby during installation.") case .unavailable: - return "This app cannot verify a compatible in-app package for this exact device. The web flasher provides the supported installation path." + return String(localized: "This app cannot verify a compatible in-app package for this exact device. The web flasher provides the supported installation path.") } } @@ -243,19 +263,19 @@ struct EventFirmwareInstallerView: View { ) -> String { switch reason { case .contractUnavailable: - return "No trusted installation contract is currently published for this event." + return String(localized: "No trusted installation contract is currently published for this event.") case .contractEditionMismatch: - return "The published installation contract is for a different event." + return String(localized: "The published installation contract is for a different event.") case .noCompatibleArtifact: - return "No package is published for this exact device target." + return String(localized: "No package is published for this exact device target.") case let .sourceFirmwareTooOld(minimum): - return "In-app installation requires device firmware \(minimum) or newer." + return String(localized: "In-app installation requires device firmware \(minimum) or newer.") case let .bootloaderTooOld(minimum): - return "In-app installation requires bootloader \(minimum) or newer." + return String(localized: "In-app installation requires bootloader \(minimum) or newer.") case .unsupportedOTAPath: - return "This device does not have an app-supported event firmware OTA path." + return String(localized: "This device does not have an app-supported event firmware OTA path.") case .untrustedArtifact: - return "The available package does not meet the app's download trust policy." + return String(localized: "The available package does not meet the app's download trust policy.") } } @@ -267,7 +287,7 @@ struct EventFirmwareInstallerView: View { activeNodeNum: accessoryManager.activeDeviceNum ) else { preparationState = .failed( - "Reconnect to this device before preparing its firmware package." + String(localized: "Reconnect to this device before preparing its firmware package.") ) return } @@ -288,7 +308,7 @@ struct EventFirmwareInstallerView: View { availability: availability ) else { preparationState = .failed( - "The connected device changed. Select the event again for the current device." + String(localized: "The connected device changed. Select the event again for the current device.") ) preparationTask = nil return @@ -311,9 +331,12 @@ struct EventFirmwareInstallerView: View { preparationTask = nil } } catch { + Logger.services.error( + "Event firmware artifact preparation failed: \(error.localizedDescription, privacy: .public)" + ) await MainActor.run { preparationState = .failed( - "The firmware package could not be downloaded and verified." + String(localized: "The firmware package could not be downloaded and verified.") ) preparationTask = nil } @@ -322,6 +345,8 @@ struct EventFirmwareInstallerView: View { } } +// MARK: - Dependencies + private enum EventFirmwareInstallerDependencies { static func downloader() -> EventFirmwareArtifactDownloader { #if DEBUG && targetEnvironment(simulator) @@ -352,6 +377,8 @@ private enum EventFirmwareInstallerDependencies { } } +// MARK: - Artifact Format + private extension EventFirmwareOTAArtifact.Format { var firmwareType: FirmwareFile.FirmwareType { switch self { @@ -363,6 +390,8 @@ private extension EventFirmwareOTAArtifact.Format { } } +// MARK: - Simulator Preview + private struct SimulatorFirmwarePreview: Identifiable { let id = UUID() let title: String diff --git a/Meshtastic/Views/Settings/Firmware/Firmware.swift b/Meshtastic/Views/Settings/Firmware/Firmware.swift index 0e48b7cca..c8d5fd0c1 100644 --- a/Meshtastic/Views/Settings/Firmware/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware/Firmware.swift @@ -412,11 +412,17 @@ private struct FirmwareContentView: View { if let locallyChosenFirmwareFile = self.locallyChosenFirmwareFile { switch type { case .otaZip: - NRFDFUSheet(firmwareToFlash: locallyChosenFirmwareFile) + NRFDFUSheet( + firmwareToFlash: locallyChosenFirmwareFile, + expectedNodeNum: node.num + ) case .uf2: UF2MassStorageView(fileURL: locallyChosenFirmwareFile) case .bin: - ESP32OTAIntroSheet(binFileURL: locallyChosenFirmwareFile) + ESP32OTAIntroSheet( + binFileURL: locallyChosenFirmwareFile, + expectedNodeNum: node.num + ) } } } diff --git a/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift b/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift index c89107f23..596085fa6 100644 --- a/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift @@ -18,9 +18,6 @@ struct NRFDFUSheet: View { var expectedNodeNum: Int64? private var isExpectedDeviceActive: Bool { - guard let expectedNodeNum else { - return accessoryManager.activeDeviceNum != nil - } return EventFirmwareInstallerPolicy.isExpectedDeviceActive( expectedNodeNum: expectedNodeNum, activeNodeNum: accessoryManager.activeDeviceNum diff --git a/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift b/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift index 771424198..447f2e886 100644 --- a/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift +++ b/MeshtasticTests/EventFirmwareArtifactDownloaderTests.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareArtifactDownloaderTests.swift + import CryptoKit import Foundation import Testing diff --git a/MeshtasticTests/EventFirmwareInstallerViewTests.swift b/MeshtasticTests/EventFirmwareInstallerViewTests.swift index 9e5d83051..ebfb01300 100644 --- a/MeshtasticTests/EventFirmwareInstallerViewTests.swift +++ b/MeshtasticTests/EventFirmwareInstallerViewTests.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareInstallerViewTests.swift + import Testing @testable import Meshtastic @@ -43,6 +45,21 @@ struct EventFirmwareInstallerViewTests { )) } + @Test func optionalDevicePolicyRequiresAnyConnectionOnlyWhenTargetIsUnspecified() { + #expect(EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: nil, + activeNodeNum: 123 + )) + #expect(!EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: nil, + activeNodeNum: nil + )) + #expect(!EventFirmwareInstallerPolicy.isExpectedDeviceActive( + expectedNodeNum: 123, + activeNodeNum: 456 + )) + } + @Test func installHandoffRequiresSameExactSelection() throws { let contract = try EventFirmwareOTADebugFixture.verifiedContract() let prepared = try #require(contract.artifacts.first).selection diff --git a/MeshtasticTests/EventFirmwareMetadataTests.swift b/MeshtasticTests/EventFirmwareMetadataTests.swift index 1db5d8970..cabb2e913 100644 --- a/MeshtasticTests/EventFirmwareMetadataTests.swift +++ b/MeshtasticTests/EventFirmwareMetadataTests.swift @@ -617,4 +617,82 @@ struct EventFirmwareCacheMergeTests { #expect(defcon.themePalette == ["#0D294A", "#E0004E"]) #expect(rows.contains { $0.edition == "FAB" && $0.displayName == "FAB26 Boston" }) } + + @Test func emptyLiveStringsPreserveCachedMetadata() async throws { + let container = try makeContainer() + let api = MeshtasticAPI(container: container, startupRefresh: false) + let complete = try payloads(from: """ + {"version":2,"editions":[{ + "edition":"DEFCON", + "displayName":"DEF CON 34", + "welcomeMessage":"Welcome", + "tag":"dc34", + "eventStart":"2026-08-06", + "eventEnd":"2026-08-09", + "timeZone":"America/Los_Angeles", + "location":"Las Vegas", + "domain":"defcon.org", + "theme":{ + "name":"DEF CON", + "tagline":"Hack the planet", + "fonts":{"heading":"DIN","body":"Inter"} + }, + "firmware":{ + "slug":"defcon34", + "version":"2.8.0.b00d76f", + "id":"release-34", + "title":"DEF CON 34 Firmware", + "releaseNotes":"Initial release" + } + }]} + """) + let empty = try payloads(from: """ + {"version":2,"editions":[{ + "edition":"DEFCON", + "displayName":"", + "welcomeMessage":"", + "tag":"", + "eventStart":"", + "eventEnd":"", + "timeZone":"", + "location":"", + "domain":"", + "theme":{ + "name":"", + "tagline":"", + "fonts":{"heading":"","body":""} + }, + "firmware":{ + "slug":"", + "version":"", + "id":"", + "title":"", + "releaseNotes":"" + } + }]} + """) + + await api.importEventEditions(complete, overwriteExisting: true) + await api.importEventEditions(empty, overwriteExisting: true) + + let rows = try container.mainContext.fetch(FetchDescriptor()) + let defcon = try #require(rows.first { $0.edition == "DEFCON" }) + #expect(defcon.displayName == "DEF CON 34") + #expect(defcon.welcomeMessage == "Welcome") + #expect(defcon.tag == "dc34") + #expect(defcon.eventStart == "2026-08-06") + #expect(defcon.eventEnd == "2026-08-09") + #expect(defcon.timeZone == "America/Los_Angeles") + #expect(defcon.location == "Las Vegas") + #expect(defcon.domain == "defcon.org") + #expect(defcon.themeName == "DEF CON") + #expect(defcon.themeTagline == "Hack the planet") + #expect(defcon.themeFontHeading == "DIN") + #expect(defcon.themeFontBody == "Inter") + #expect(defcon.firmwareSlug == "defcon34") + #expect(defcon.firmwareVersion == "2.8.0.b00d76f") + #expect(defcon.firmwareId == "release-34") + #expect(defcon.firmwareTitle == "DEF CON 34 Firmware") + #expect(defcon.firmwareReleaseNotes == "Initial release") + } } diff --git a/MeshtasticTests/EventFirmwareOTAContractTests.swift b/MeshtasticTests/EventFirmwareOTAContractTests.swift index ac3ac8a4e..9ad18f373 100644 --- a/MeshtasticTests/EventFirmwareOTAContractTests.swift +++ b/MeshtasticTests/EventFirmwareOTAContractTests.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareOTAContractTests.swift + import CryptoKit import Foundation import Testing diff --git a/MeshtasticTests/EventFirmwareOTASelectorTests.swift b/MeshtasticTests/EventFirmwareOTASelectorTests.swift index 058eddeab..00d0b6969 100644 --- a/MeshtasticTests/EventFirmwareOTASelectorTests.swift +++ b/MeshtasticTests/EventFirmwareOTASelectorTests.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareOTASelectorTests.swift + import Foundation import Testing diff --git a/MeshtasticTests/EventFirmwareOTAServiceTests.swift b/MeshtasticTests/EventFirmwareOTAServiceTests.swift index 9ecd9796e..6d830fb11 100644 --- a/MeshtasticTests/EventFirmwareOTAServiceTests.swift +++ b/MeshtasticTests/EventFirmwareOTAServiceTests.swift @@ -1,3 +1,5 @@ +// MARK: EventFirmwareOTAServiceTests.swift + import Foundation import Testing