Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
36 changes: 19 additions & 17 deletions Meshtastic/API/MeshtasticAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1070,21 +1070,21 @@ 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
}
if let value = payload.accentColor,
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)
Expand All @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
"images" : [
{
"filename" : "hamvention.png",
"idiom" : "universal",
"scale" : "1x"
"idiom" : "universal"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
"info" : {
Expand Down
172 changes: 172 additions & 0 deletions Meshtastic/Model/EventFirmwareArtifactDownloader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// MARK: EventFirmwareArtifactDownloader.swift

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

/// Cancels a URL session download task as soon as its streamed byte count exceeds a trusted limit.
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"
}
}
}
Loading
Loading