diff --git a/Hemera/AppEnvironment.swift b/Hemera/AppEnvironment.swift index 0838850..f02f4d9 100644 --- a/Hemera/AppEnvironment.swift +++ b/Hemera/AppEnvironment.swift @@ -29,7 +29,13 @@ final class AppEnvironment { } catch { Log.error("ModelContainer creation failed — deleting store (HomeTile layout will be lost). Error: \(error)") Self.deleteStore(at: modelConfig.url) - self.container = try! ModelContainer(for: schema, configurations: modelConfig) + do { + self.container = try ModelContainer(for: schema, configurations: modelConfig) + } catch { + Log.error("ModelContainer recreation failed after store deletion — falling back to in-memory container (no persistence this session). Error: \(error)") + let memoryConfig = ModelConfiguration(isStoredInMemoryOnly: true) + self.container = try! ModelContainer(for: schema, configurations: memoryConfig) + } } self.storage = SwiftDataStorage(context: container.mainContext) diff --git a/Hemera/HomeAssistant/MDISymbolMapper.swift b/Hemera/HomeAssistant/MDISymbolMapper.swift index 5c80ccb..4412bb1 100644 --- a/Hemera/HomeAssistant/MDISymbolMapper.swift +++ b/Hemera/HomeAssistant/MDISymbolMapper.swift @@ -1,4 +1,5 @@ import Foundation +import HemeraLog /// Maps Material Design Icon (MDI) names from Home Assistant to SF Symbol names. /// @@ -31,18 +32,24 @@ enum MDISymbolMapper { private static func loadMapping(from resource: String) -> [String: String] { guard let url = Bundle.main.url(forResource: resource, withExtension: "json") else { - preconditionFailure("\(resource).json missing from bundle") + Log.error("\(resource).json missing from bundle — icon mapping disabled for \(resource)") + return [:] } - let data = try! Data(contentsOf: url) - let entries = try! JSONDecoder().decode([Entry].self, from: data) - - var map: [String: String] = [:] - for entry in entries { - for mdiName in entry.mdiNames { - map[mdiName] = entry.sfSymbol + do { + let data = try Data(contentsOf: url) + let entries = try JSONDecoder().decode([Entry].self, from: data) + + var map: [String: String] = [:] + for entry in entries { + for mdiName in entry.mdiNames { + map[mdiName] = entry.sfSymbol + } } + return map + } catch { + Log.error("Failed to load \(resource).json — icon mapping disabled", cause: error) + return [:] } - return map } } diff --git a/Hemera/SessionManager.swift b/Hemera/SessionManager.swift index 38252f9..94600a1 100644 --- a/Hemera/SessionManager.swift +++ b/Hemera/SessionManager.swift @@ -88,7 +88,10 @@ final class SessionManager: ConnectionRetrying { Log.info("Starting demo session") let demoConfig = ModelConfiguration(isStoredInMemoryOnly: true) - let demoContainer = try! ModelContainer(for: AppEnvironment.createSchema(), configurations: demoConfig) + guard let demoContainer = try? ModelContainer(for: AppEnvironment.createSchema(), configurations: demoConfig) else { + Log.error("Failed to create in-memory demo container — aborting demo session") + return + } self.demoContainer = demoContainer let demoContext = demoContainer.mainContext diff --git a/Hemera/UI/RootView.swift b/Hemera/UI/RootView.swift index 2fedcbe..32bc2c9 100644 --- a/Hemera/UI/RootView.swift +++ b/Hemera/UI/RootView.swift @@ -24,9 +24,18 @@ struct RootView: View { case .connecting: ConnectingView(viewModel: ConnectingViewModel()) case .authenticated: - MainTabView(viewModel: MainTabViewModel()) - .modelContainer(ServiceLocator.shared.session!.container) - .transaction { $0.animation = nil } + if let session = ServiceLocator.shared.session { + MainTabView(viewModel: MainTabViewModel()) + .modelContainer(session.container) + .transaction { $0.animation = nil } + } else { + /** + Session torn down while destination is briefly still .authenticated. + Render nothing rather than crashing; the router handler will move + destination away from .authenticated on the same runloop turn. + */ + Color.clear + } } } .environment(authManager) diff --git a/Hemera/UI/Settings/AuthenticatedWebView.swift b/Hemera/UI/Settings/AuthenticatedWebView.swift index 0ea7548..e81c170 100644 --- a/Hemera/UI/Settings/AuthenticatedWebView.swift +++ b/Hemera/UI/Settings/AuthenticatedWebView.swift @@ -43,11 +43,16 @@ struct AuthenticatedWebView: UIViewRepresentable { let webView = WKWebView(frame: .zero, configuration: config) context.coordinator.webView = webView - var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! - components.queryItems = (components.queryItems ?? []) + [ - URLQueryItem(name: "external_auth", value: "1") - ] - webView.load(URLRequest(url: components.url!)) + let requestURL: URL + if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + components.queryItems = (components.queryItems ?? []) + [ + URLQueryItem(name: "external_auth", value: "1") + ] + requestURL = components.url ?? url + } else { + requestURL = url + } + webView.load(URLRequest(url: requestURL)) return webView } diff --git a/HemeraTests/HomeAssistant/MDISymbolMapperTests.swift b/HemeraTests/HomeAssistant/MDISymbolMapperTests.swift index 8a2ea27..2ba21f3 100644 --- a/HemeraTests/HomeAssistant/MDISymbolMapperTests.swift +++ b/HemeraTests/HomeAssistant/MDISymbolMapperTests.swift @@ -144,6 +144,14 @@ struct MDISymbolMapperTests { } } + @Test func bundledMaps_decodeToNonEmpty() throws { + for file in ["AreaMdiToSymbolMap", "EntityMdiToSymbolMap"] { + let entries = try loadEntries(from: file) + #expect(!entries.isEmpty) + #expect(entries.allSatisfy { !$0.mdiNames.isEmpty }) + } + } + // MARK: - Helpers private struct MappingEntry: Decodable { diff --git a/HemeraTests/Navigation/SessionManagerDemoTests.swift b/HemeraTests/Navigation/SessionManagerDemoTests.swift index fcbef7e..077f044 100644 --- a/HemeraTests/Navigation/SessionManagerDemoTests.swift +++ b/HemeraTests/Navigation/SessionManagerDemoTests.swift @@ -63,6 +63,19 @@ struct SessionManagerTests { #expect(ServiceLocator.shared.session != nil) } + @Test + func inMemoryDemoContainer_withCurrentSchema_buildsAndIsQueryable() throws { + /** + Guards the graceful demo-container fallback in `startDemoSession`: if the + current schema ever stopped building an in-memory container, the demo path + would silently no-op. This fails loudly instead. + */ + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: AppEnvironment.createSchema(), configurations: config) + let count = try container.mainContext.fetchCount(FetchDescriptor()) + #expect(count == 0) + } + // MARK: - tearDownDemoSession @Test