From 56870bd83fa90672f2d329cdef92dcf5f4c80492 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 18:34:04 +0200 Subject: [PATCH 1/2] Harden crash-prone force-unwraps and traps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defensive fixes from the code audit — four independent crash/robustness findings across disjoint files. Each converts a runtime-fallible force-unwrap or `try!` into graceful degradation, per .claude/rules/swift-style.md. - AppEnvironment: the on-disk ModelContainer recreate retry was `try!`. If store deletion silently fails or the store is genuinely incompatible, the retry throws and traps → unrecoverable boot loop. Wrap the retry in a second do/catch and fall back to an in-memory container so the app still boots (no persistence this session). The last-resort in-memory `try!` cannot realistically fail (schema already validated). - SessionManager.startDemoSession: in-memory demo container built with `try!`. Convert to `guard let try?` + log and bail. buildTabViewModels already guards `session`, and RootView now tolerates a nil session, so demo entry degrades to a no-op instead of crashing. - MDISymbolMapper.loadMapping: `try! Data(contentsOf:)` and `try! decode` aborted the process on any read/decode failure of the bundled MDI JSON. Degrade to an empty map + log (icons fall back to unmapped). Added `import HemeraLog`. Missing-resource preconditionFailure downgraded to a log for consistency. Added bundledMaps_decodeToNonEmpty regression test. - AuthenticatedWebView: force-unwrapped URLComponents decomposition and recomposition on a credential-derived server URL. Guard both; fall back to the original url (omitting external_auth=1) if decomposition fails. - RootView: `.authenticated` case force-unwrapped ServiceLocator.shared.session. Safety was implicit in handler-registration ordering. Make it local with `if let session`, rendering Color.clear otherwise. Ordering unchanged. Tests: added inMemoryDemoContainer_withCurrentSchema_buildsAndIsQueryable and MDISymbolMapperTests.bundledMaps_decodeToNonEmpty. ModelContainer-failure and RootView rendering paths are not unit-testable; verified by clean build + reasoning. HemeraTests green on iPhone 16 Pro. Co-Authored-By: Claude Opus 4.8 --- Hemera/AppEnvironment.swift | 8 +++++- Hemera/HomeAssistant/MDISymbolMapper.swift | 25 ++++++++++++------- Hemera/SessionManager.swift | 5 +++- Hemera/UI/RootView.swift | 13 +++++++--- Hemera/UI/Settings/AuthenticatedWebView.swift | 15 +++++++---- .../HomeAssistant/MDISymbolMapperTests.swift | 8 ++++++ .../Navigation/SessionManagerDemoTests.swift | 11 ++++++++ 7 files changed, 66 insertions(+), 19 deletions(-) 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..f1f15b1 100644 --- a/Hemera/UI/RootView.swift +++ b/Hemera/UI/RootView.swift @@ -24,9 +24,16 @@ 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..4959820 100644 --- a/HemeraTests/Navigation/SessionManagerDemoTests.swift +++ b/HemeraTests/Navigation/SessionManagerDemoTests.swift @@ -63,6 +63,17 @@ 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 From 4ccf187ddc9e5b10a39839ec89f4e175444bcad1 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 21:41:26 +0200 Subject: [PATCH 2/2] Use /** */ block form for multi-line comments Project convention: multi-line explanatory comments use the `/** ... */` block form. Converts the two multi-line `//` comments introduced in the crash-hardening change (RootView's nil-session fallback rationale, and the demo-container test's intent note). Comment-only change; build verified. Co-Authored-By: Claude Opus 4.8 --- Hemera/UI/RootView.swift | 8 +++++--- HemeraTests/Navigation/SessionManagerDemoTests.swift | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Hemera/UI/RootView.swift b/Hemera/UI/RootView.swift index f1f15b1..32bc2c9 100644 --- a/Hemera/UI/RootView.swift +++ b/Hemera/UI/RootView.swift @@ -29,9 +29,11 @@ struct RootView: View { .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. + /** + 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 } } diff --git a/HemeraTests/Navigation/SessionManagerDemoTests.swift b/HemeraTests/Navigation/SessionManagerDemoTests.swift index 4959820..077f044 100644 --- a/HemeraTests/Navigation/SessionManagerDemoTests.swift +++ b/HemeraTests/Navigation/SessionManagerDemoTests.swift @@ -65,9 +65,11 @@ struct SessionManagerTests { @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. + /** + 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())