@@ -116,6 +116,7 @@ class MeshtasticAPI: ObservableObject, @unchecked Sendable {
116116 static let deviceURLEndpoint = URL ( string: " https://api.meshtastic.org/resource/deviceHardware " ) !
117117 static let imageURLPrefix = URL ( string: " https://flasher.meshtastic.org/img/devices/ " ) !
118118 static let firmwareURLEndpoint = URL ( string: " https://api.meshtastic.org/github/firmware/list " ) !
119+ static let eventFirmwareURLEndpoint = URL ( string: " https://api.meshtastic.org/resource/eventFirmware " ) !
119120
120121 // MARK: - Private properties
121122 private let fileManager = FileManager . default
@@ -132,10 +133,15 @@ class MeshtasticAPI: ObservableObject, @unchecked Sendable {
132133 // Load bundled catalog first — instant display, no network needed.
133134 try ? await self . refreshBundledDevicesData ( )
134135 try ? await self . refreshFirmwareAPIData ( )
136+ // Seed event-firmware branding from the bundle so it survives restarts offline.
137+ await self . refreshBundledEventFirmwareData ( )
135138 // Then silently update from the live API in the background.
136139 Task . detached ( priority: . utility) {
137140 try ? await self . refreshDevicesAPIData ( )
138141 }
142+ Task . detached ( priority: . utility) {
143+ await self . refreshEventFirmwareAPIData ( )
144+ }
139145 }
140146 }
141147
@@ -632,3 +638,182 @@ extension MeshtasticAPI {
632638 return bundledData
633639 }
634640}
641+
642+ // MARK: - Event Firmware Metadata
643+
644+ // Decoding structs for `GET https://api.meshtastic.org/resource/eventFirmware` (version 2).
645+ // Every field except `edition` is independently optional; a new event may ship with only a
646+ // subset populated, so all use `decodeIfPresent`.
647+ private struct EventFirmwareFile : Codable {
648+ let version : Int ?
649+ let editions : [ EventFirmwarePayload ]
650+ }
651+
652+ private struct EventFirmwarePayload : Codable {
653+ let edition : String
654+ let displayName : String ?
655+ let welcomeMessage : String ?
656+ let tag : String ?
657+ let eventStart : String ?
658+ let eventEnd : String ?
659+ let timeZone : String ?
660+ let location : String ?
661+ let iconUrl : String ?
662+ let accentColor : String ?
663+ let domain : String ?
664+ let links : [ EventFirmwareLinkPayload ] ?
665+ let theme : EventFirmwareThemePayload ?
666+ let firmware : EventFirmwareBuildPayload ?
667+ }
668+
669+ private struct EventFirmwareLinkPayload : Codable {
670+ let label : String
671+ let url : String
672+ }
673+
674+ private struct EventFirmwareThemePayload : Codable {
675+ let name : String ?
676+ let tagline : String ?
677+ let palette : [ String ] ?
678+ let fonts : EventFirmwareFontsPayload ?
679+ }
680+
681+ private struct EventFirmwareFontsPayload : Codable {
682+ let heading : String ?
683+ let body : String ?
684+ }
685+
686+ private struct EventFirmwareBuildPayload : Codable {
687+ let slug : String ?
688+ let version : String ?
689+ let id : String ?
690+ let title : String ?
691+ let zipUrl : String ?
692+ let releaseNotes : String ?
693+ }
694+
695+ extension MeshtasticAPI {
696+
697+ /// Load the bundled `event_firmware.json` seed into the cache. Runs at launch so event
698+ /// branding is available instantly and survives restarts offline (the live refresh then
699+ /// updates it in the background). Never throws — a missing/corrupt bundle is logged and
700+ /// leaves any existing cache intact.
701+ func refreshBundledEventFirmwareData( ) async {
702+ guard container != nil else { return }
703+ guard let bundledURL = Bundle . main. url ( forResource: " event_firmware " , withExtension: " json " ) ,
704+ let data = try ? Data ( contentsOf: bundledURL) ,
705+ let decoded = try ? decoder. decode ( EventFirmwareFile . self, from: data) else {
706+ Logger . services. warning ( " Unable to load bundled event_firmware.json " )
707+ return
708+ }
709+ // pruneMissing: false — the bundle is a *floor*, not authoritative. It must never delete
710+ // an edition that a prior successful live refresh cached but that isn't in the bundled
711+ // snapshot; that edition would otherwise vanish on every offline relaunch (the exact
712+ // poor-connectivity-at-the-venue scenario this cache exists for).
713+ await importEventEditions ( decoded. editions, pruneMissing: false )
714+ Logger . services. info ( " Loaded bundled event firmware ( \( decoded. editions. count, privacy: . public) editions) " )
715+ }
716+
717+ /// Silently refresh the event-firmware metadata from the live API.
718+ ///
719+ /// IMPORTANT: this deliberately does NOT use the short `data(timeout:)` deadline the device
720+ /// and firmware calls use. `api.meshtastic.org` is measured at 20–60s for this resource; a
721+ /// short deadline cancels every refresh and pins users to the bundled seed (Android hit
722+ /// exactly this and removed its local deadline). We rely on the default URLSession timeout
723+ /// plus stale-while-revalidate. An empty or failed response is a **no-op** — it must leave
724+ /// the existing cache intact, never wipe it.
725+ func refreshEventFirmwareAPIData( ) async {
726+ guard container != nil else { return }
727+
728+ var request = URLRequest ( url: Self . eventFirmwareURLEndpoint)
729+ // No short local timeout — see the doc comment above. Revalidate against the server
730+ // cache so a 304 is cheap while still surfacing new events.
731+ request. cachePolicy = . reloadRevalidatingCacheData
732+
733+ guard let ( data, response) = try ? await URLSession . shared. data ( for: request) ,
734+ let http = response as? HTTPURLResponse , ( 200 ..< 300 ) . contains ( http. statusCode) ,
735+ !data. isEmpty else {
736+ Logger . services. warning ( " Event firmware API fetch failed or empty — keeping cached editions " )
737+ return
738+ }
739+
740+ guard let decoded = try ? decoder. decode ( EventFirmwareFile . self, from: data) ,
741+ !decoded. editions. isEmpty else {
742+ // A decode failure or an editions-less payload must not clobber the cache.
743+ Logger . services. warning ( " Event firmware API returned no usable editions — keeping cache " )
744+ return
745+ }
746+
747+ // pruneMissing: true — the live API is authoritative, so an edition it no longer lists is
748+ // genuinely retired and should be removed from the cache.
749+ await importEventEditions ( decoded. editions, pruneMissing: true )
750+ UserDefaults . lastEventFirmwareAPIUpdate = Date ( )
751+ Logger . services. info ( " Refreshed event firmware from API ( \( decoded. editions. count, privacy: . public) editions) " )
752+ }
753+
754+ /// Upsert the given editions into `EventFirmwareEntity`. When `pruneMissing` is true, also
755+ /// delete rows not present in `editions` — this is reserved for the **authoritative** live-API
756+ /// import. The bundled seed passes `pruneMissing: false` so it only upserts/seeds and never
757+ /// deletes an edition cached from a prior successful API refresh. Called with a non-empty
758+ /// list from both paths; the caller guarantees an empty/failed fetch never reaches here.
759+ private func importEventEditions( _ editions: [ EventFirmwarePayload ] , pruneMissing: Bool ) async {
760+ guard let container else { return }
761+ await MainActor . run {
762+ let context = container. mainContext
763+ let importedKeys = Set ( editions. map { $0. edition } )
764+
765+ for payload in editions {
766+ let key = payload. edition
767+ var descriptor = FetchDescriptor < EventFirmwareEntity > (
768+ predicate: #Predicate { $0. edition == key }
769+ )
770+ descriptor. fetchLimit = 1
771+
772+ let entity : EventFirmwareEntity
773+ if let existing = try ? context. fetch ( descriptor) . first {
774+ entity = existing
775+ } else {
776+ entity = EventFirmwareEntity ( edition: key)
777+ context. insert ( entity)
778+ }
779+
780+ entity. displayName = payload. displayName
781+ entity. welcomeMessage = payload. welcomeMessage
782+ entity. tag = payload. tag
783+ entity. eventStart = payload. eventStart
784+ entity. eventEnd = payload. eventEnd
785+ entity. timeZone = payload. timeZone
786+ entity. location = payload. location
787+ entity. iconUrl = payload. iconUrl
788+ entity. accentColor = payload. accentColor
789+ entity. domain = payload. domain
790+ entity. setLinks ( ( payload. links ?? [ ] ) . map {
791+ EventFirmwareEntity . Link ( label: $0. label, url: $0. url)
792+ } )
793+ entity. themeName = payload. theme? . name
794+ entity. themeTagline = payload. theme? . tagline
795+ entity. themePalette = payload. theme? . palette ?? [ ]
796+ entity. themeFontHeading = payload. theme? . fonts? . heading
797+ entity. themeFontBody = payload. theme? . fonts? . body
798+ entity. firmwareSlug = payload. firmware? . slug
799+ entity. firmwareVersion = payload. firmware? . version
800+ entity. firmwareId = payload. firmware? . id
801+ entity. firmwareTitle = payload. firmware? . title
802+ entity. firmwareZipUrl = payload. firmware? . zipUrl
803+ entity. firmwareReleaseNotes = payload. firmware? . releaseNotes
804+ }
805+
806+ // Delete editions no longer present — only for the authoritative live-API import.
807+ if pruneMissing {
808+ let allDescriptor = FetchDescriptor < EventFirmwareEntity > ( )
809+ if let all = try ? context. fetch ( allDescriptor) {
810+ for row in all where !importedKeys. contains ( row. edition) {
811+ context. delete ( row)
812+ }
813+ }
814+ }
815+
816+ try ? context. save ( )
817+ }
818+ }
819+ }
0 commit comments