From 384aef67eb8829bf6892648d0aa24f26528e985d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:07:24 +0000 Subject: [PATCH 1/8] Initial plan From 28c1e7531304d739a0efb0c8a84cfdaf516d3e70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:16:35 +0000 Subject: [PATCH 2/8] Add CarPlay config export/import with UI and localization Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> --- .../Resources/en.lproj/Localizable.strings | 8 +++ .../CarPlay/CarPlayConfigurationView.swift | 61 ++++++++++++++++++ .../CarPlayConfigurationViewModel.swift | 63 +++++++++++++++++++ .../Shared/Resources/Swiftgen/Strings.swift | 36 +++++++++++ 4 files changed, 168 insertions(+) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 2c40ce1598..839e5f3aa0 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -119,6 +119,14 @@ "carPlay.debug.delete_db.alert.title" = "Are you sure you want to delete CarPlay configuration? This can't be reverted"; "carPlay.debug.delete_db.button.title" = "Delete CarPlay configuration"; "carPlay.debug.delete_db.reset.title" = "Reset configuration"; +"carPlay.export.button.title" = "Share Configuration"; +"carPlay.export.error.message" = "Failed to export configuration: %@"; +"carPlay.import.button.title" = "Import Configuration"; +"carPlay.import.confirmation.title" = "Import CarPlay Configuration?"; +"carPlay.import.confirmation.message" = "This will replace your current CarPlay configuration. This action cannot be undone."; +"carPlay.import.error.message" = "Failed to import configuration: %@"; +"carPlay.import.error.invalid_file" = "Invalid configuration file"; +"carPlay.import.success.message" = "Configuration imported successfully"; "carPlay.labels.already_added_server" = "Already added"; "carPlay.labels.empty_domain_list" = "No domains available"; "carPlay.labels.no_servers_available" = "No servers available. Add a server in the app."; diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift index d3ad45299b..f89eaf62cb 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift @@ -3,6 +3,7 @@ import SFSafeSymbols import Shared import StoreKit import SwiftUI +import UniformTypeIdentifiers struct CarPlayConfigurationView: View { @Environment(\.dismiss) private var dismiss @@ -10,6 +11,11 @@ struct CarPlayConfigurationView: View { @State private var isLoaded = false @State private var showResetConfirmation = false + @State private var showShareSheet = false + @State private var exportedFileURL: URL? + @State private var showImportPicker = false + @State private var showImportConfirmation = false + @State private var importURL: URL? var body: some View { content @@ -48,6 +54,41 @@ struct CarPlayConfigurationView: View { Text(verbatim: L10n.okLabel) }) } + .sheet(isPresented: $showShareSheet) { + if let url = exportedFileURL { + ShareActivityView(activityItems: [url]) + } + } + .fileImporter( + isPresented: $showImportPicker, + allowedContentTypes: [.init(filenameExtension: "homeassistant") ?? .data], + allowsMultipleSelection: false + ) { result in + switch result { + case let .success(urls): + if let url = urls.first { + importURL = url + showImportConfirmation = true + } + case let .failure(error): + Current.Log.error("File import failed: \(error.localizedDescription)") + viewModel.showError = true + } + } + .alert(L10n.CarPlay.Import.Confirmation.title, isPresented: $showImportConfirmation) { + Button(L10n.yesLabel, role: .destructive) { + if let url = importURL { + viewModel.importConfiguration(from: url) { success in + if success { + viewModel.loadConfig() + } + } + } + } + Button(L10n.noLabel, role: .cancel) {} + } message: { + Text(L10n.CarPlay.Import.Confirmation.message) + } } private var content: some View { @@ -55,6 +96,7 @@ struct CarPlayConfigurationView: View { carPlayLogo tabsSection itemsSection + exportImportSection resetView } } @@ -217,6 +259,25 @@ struct CarPlayConfigurationView: View { Button(L10n.noLabel, role: .cancel) {} } } + + private var exportImportSection: some View { + Section { + Button { + if let url = viewModel.exportConfiguration() { + exportedFileURL = url + showShareSheet = true + } + } label: { + Label(L10n.CarPlay.Export.Button.title, systemSymbol: .squareAndArrowUp) + } + + Button { + showImportPicker = true + } label: { + Label(L10n.CarPlay.Import.Button.title, systemSymbol: .squareAndArrowDown) + } + } + } } #Preview { diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift index bf76faaccf..7aa7f52f4d 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift @@ -146,4 +146,67 @@ final class CarPlayConfigurationViewModel: ObservableObject { func moveItem(from source: IndexSet, to destination: Int) { config.quickAccessItems.move(fromOffsets: source, toOffset: destination) } + + // MARK: - Export/Import + + func exportConfiguration() -> URL? { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + + let tempDirectory = FileManager.default.temporaryDirectory + let fileName = "CarPlay.homeassistant" + let fileURL = tempDirectory.appendingPathComponent(fileName) + + try data.write(to: fileURL) + Current.Log.info("CarPlay configuration exported to \(fileURL.path)") + + return fileURL + } catch { + Current.Log.error("Failed to export CarPlay configuration: \(error.localizedDescription)") + showError(message: L10n.CarPlay.Export.Error.message(error.localizedDescription)) + return nil + } + } + + @MainActor + func importConfiguration(from url: URL, completion: @escaping (Bool) -> Void) { + do { + guard url.startAccessingSecurityScopedResource() else { + showError(message: L10n.CarPlay.Import.Error.invalidFile) + completion(false) + return + } + + defer { + url.stopAccessingSecurityScopedResource() + } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + var importedConfig = try decoder.decode(CarPlayConfig.self, from: data) + + // Migrate items to match current server IDs + importedConfig.quickAccessItems = magicItemProvider.migrateItemsIfNeeded(items: importedConfig.quickAccessItems) + + // Update configuration + setConfig(importedConfig) + + // Save to database + save { success in + if success { + Current.Log.info("CarPlay configuration imported successfully") + completion(true) + } else { + Current.Log.error("Failed to save imported configuration") + completion(false) + } + } + } catch { + Current.Log.error("Failed to import CarPlay configuration: \(error.localizedDescription)") + showError(message: L10n.CarPlay.Import.Error.message(error.localizedDescription)) + completion(false) + } + } } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 595e966d16..5de3eb7427 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -557,6 +557,42 @@ public enum L10n { } } } + public enum Export { + public enum Button { + /// Share Configuration + public static var title: String { return L10n.tr("Localizable", "carPlay.export.button.title") } + } + public enum Error { + /// Failed to export configuration: %@ + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "carPlay.export.error.message", String(describing: p1)) + } + } + } + public enum Import { + public enum Button { + /// Import Configuration + public static var title: String { return L10n.tr("Localizable", "carPlay.import.button.title") } + } + public enum Confirmation { + /// This will replace your current CarPlay configuration. This action cannot be undone. + public static var message: String { return L10n.tr("Localizable", "carPlay.import.confirmation.message") } + /// Import CarPlay Configuration? + public static var title: String { return L10n.tr("Localizable", "carPlay.import.confirmation.title") } + } + public enum Error { + /// Invalid configuration file + public static var invalidFile: String { return L10n.tr("Localizable", "carPlay.import.error.invalid_file") } + /// Failed to import configuration: %@ + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "carPlay.import.error.message", String(describing: p1)) + } + } + public enum Success { + /// Configuration imported successfully + public static var message: String { return L10n.tr("Localizable", "carPlay.import.success.message") } + } + } public enum Labels { /// Already added public static var alreadyAddedServer: String { return L10n.tr("Localizable", "carPlay.labels.already_added_server") } From 044016d96bd7c47392c62f12741b6bd1f2bb8a44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:18:09 +0000 Subject: [PATCH 3/8] Add test for CarPlay config export/import and expose migration method Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> --- .../Shared/MagicItem/MagicItemProvider.swift | 1 + .../CarPlayConfigExportImport.test.swift | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 Tests/App/CarPlay/CarPlayConfigExportImport.test.swift diff --git a/Sources/Shared/MagicItem/MagicItemProvider.swift b/Sources/Shared/MagicItem/MagicItemProvider.swift index fcb38eea2f..d53d6182f6 100644 --- a/Sources/Shared/MagicItem/MagicItemProvider.swift +++ b/Sources/Shared/MagicItem/MagicItemProvider.swift @@ -6,6 +6,7 @@ public protocol MagicItemProviderProtocol { func loadInformation(completion: @escaping ([String: [HAAppEntity]]) -> Void) func loadInformation() async -> [String: [HAAppEntity]] func getInfo(for item: MagicItem) -> MagicItem.Info? + func migrateItemsIfNeeded(items: [MagicItem]) -> [MagicItem] } final class MagicItemProvider: MagicItemProviderProtocol { diff --git a/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift b/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift new file mode 100644 index 0000000000..83c7acb588 --- /dev/null +++ b/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift @@ -0,0 +1,66 @@ +import Foundation +@testable import Shared +import Testing + +struct CarPlayConfigExportImportTests { + @Test func testCarPlayConfigEncodingDecoding() throws { + // Create a test configuration + let config = CarPlayConfig( + id: "carplay-config", + tabs: [.quickAccess, .areas, .domains], + quickAccessItems: [ + .init( + id: "script.test_script", + serverId: "test-server-id", + type: .script, + customization: .init( + iconColor: "FF0000", + requiresConfirmation: true + ) + ), + .init( + id: "scene.test_scene", + serverId: "test-server-id", + type: .scene + ), + ] + ) + + // Test encoding + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + + #expect(data.count > 0, "Encoded data should not be empty") + + // Test decoding + let decoder = JSONDecoder() + let decodedConfig = try decoder.decode(CarPlayConfig.self, from: data) + + // Verify configuration matches + #expect(decodedConfig.id == config.id, "Config ID should match") + #expect(decodedConfig.tabs == config.tabs, "Tabs should match") + #expect(decodedConfig.quickAccessItems.count == config.quickAccessItems.count, "Items count should match") + #expect( + decodedConfig.quickAccessItems[0].id == config.quickAccessItems[0].id, + "First item ID should match" + ) + #expect( + decodedConfig.quickAccessItems[0].serverId == config.quickAccessItems[0].serverId, + "First item server ID should match" + ) + #expect( + decodedConfig.quickAccessItems[0].type == config.quickAccessItems[0].type, + "First item type should match" + ) + } + + @Test func testCarPlayConfigFileExtension() throws { + let fileName = "CarPlay.homeassistant" + let tempDirectory = FileManager.default.temporaryDirectory + let fileURL = tempDirectory.appendingPathComponent(fileName) + + #expect(fileURL.pathExtension == "homeassistant", "File extension should be homeassistant") + #expect(fileURL.lastPathComponent == fileName, "File name should match") + } +} From 3bf6676c145655366f5c00b58b1d3d6a1232f292 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:20:51 +0000 Subject: [PATCH 4/8] Address code review feedback: improve error handling and file type fallback Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> --- Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift | 2 +- .../App/Settings/CarPlay/CarPlayConfigurationViewModel.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift index f89eaf62cb..93dc3ddf0d 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift @@ -61,7 +61,7 @@ struct CarPlayConfigurationView: View { } .fileImporter( isPresented: $showImportPicker, - allowedContentTypes: [.init(filenameExtension: "homeassistant") ?? .data], + allowedContentTypes: [.init(filenameExtension: "homeassistant") ?? .json], allowsMultipleSelection: false ) { result in switch result { diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift index 7aa7f52f4d..ea2df904d2 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift @@ -200,6 +200,7 @@ final class CarPlayConfigurationViewModel: ObservableObject { completion(true) } else { Current.Log.error("Failed to save imported configuration") + showError(message: L10n.CarPlay.Import.Error.message("Failed to save configuration")) completion(false) } } From 036215a9ffedec8a254ea2ae604e5c6be098b189 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:14:03 +0000 Subject: [PATCH 5/8] Expand to generic versioned export/import for CarPlay, Watch, and Widgets configs - Created ConfigurationExportable protocol with versioning support - Added ConfigurationExport container with v1 format - Made CarPlayConfig, WatchConfig, and CustomWidget conform to protocol - Created ConfigurationManager for centralized import/export - Updated CarPlayConfigurationViewModel to use new infrastructure - Added export/import UI to WatchConfigurationView - Added URL handler for homeassistant://import-config - File names now include version: HomeAssistant-CarPlay-v1.homeassistant Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> --- .../WatchConfigurationView.swift | 61 +++++++ .../WatchConfigurationViewModel.swift | 29 ++++ .../App/Settings/CarPlay/CarPlayConfig.swift | 6 +- .../CarPlayConfigurationViewModel.swift | 58 ++----- Sources/App/WebView/IncomingURLHandler.swift | 84 ++++++++-- .../Config/ConfigurationExportable.swift | 151 ++++++++++++++++++ .../Shared/Config/ConfigurationManager.swift | 120 ++++++++++++++ Sources/Shared/Watch/WatchConfig.swift | 6 +- Sources/Shared/Widget/CustomWidget.swift | 6 +- 9 files changed, 463 insertions(+), 58 deletions(-) create mode 100644 Sources/Shared/Config/ConfigurationExportable.swift create mode 100644 Sources/Shared/Config/ConfigurationManager.swift diff --git a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift index 74fbd1fb86..e3128ed2a0 100644 --- a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift +++ b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift @@ -2,6 +2,7 @@ import SFSafeSymbols import Shared import StoreKit import SwiftUI +import UniformTypeIdentifiers struct WatchConfigurationView: View { @Environment(\.dismiss) private var dismiss @@ -9,6 +10,11 @@ struct WatchConfigurationView: View { @State private var isLoaded = false @State private var showResetConfirmation = false + @State private var showShareSheet = false + @State private var exportedFileURL: URL? + @State private var showImportPicker = false + @State private var showImportConfirmation = false + @State private var importURL: URL? var body: some View { content @@ -42,6 +48,41 @@ struct WatchConfigurationView: View { Text(verbatim: L10n.okLabel) }) } + .sheet(isPresented: $showShareSheet) { + if let url = exportedFileURL { + ShareActivityView(activityItems: [url]) + } + } + .fileImporter( + isPresented: $showImportPicker, + allowedContentTypes: [.init(filenameExtension: "homeassistant") ?? .json], + allowsMultipleSelection: false + ) { result in + switch result { + case let .success(urls): + if let url = urls.first { + importURL = url + showImportConfirmation = true + } + case let .failure(error): + Current.Log.error("File import failed: \(error.localizedDescription)") + viewModel.showError = true + } + } + .alert(L10n.CarPlay.Import.Confirmation.title, isPresented: $showImportConfirmation) { + Button(L10n.yesLabel, role: .destructive) { + if let url = importURL { + viewModel.importConfiguration(from: url) { success in + if success { + viewModel.loadWatchConfig() + } + } + } + } + Button(L10n.noLabel, role: .cancel) {} + } message: { + Text(L10n.CarPlay.Import.Confirmation.message) + } } private var content: some View { @@ -56,6 +97,7 @@ struct WatchConfigurationView: View { } itemsSection assistSection + exportImportSection resetView } .preferredColorScheme(.dark) @@ -81,6 +123,25 @@ struct WatchConfigurationView: View { } } + private var exportImportSection: some View { + Section { + Button { + if let url = viewModel.exportConfiguration() { + exportedFileURL = url + showShareSheet = true + } + } label: { + Label(L10n.CarPlay.Export.Button.title, systemSymbol: .squareAndArrowUp) + } + + Button { + showImportPicker = true + } label: { + Label(L10n.CarPlay.Import.Button.title, systemSymbol: .squareAndArrowDown) + } + } + } + private var itemsSection: some View { Section(L10n.Watch.Configuration.Items.title) { ForEach(viewModel.watchConfig.items, id: \.serverUniqueId) { item in diff --git a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift index ecfa99350b..1eb3036ec7 100644 --- a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift +++ b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift @@ -129,4 +129,33 @@ final class WatchConfigurationViewModel: ObservableObject { self?.showError = true } } + + // MARK: - Export/Import + + func exportConfiguration() -> URL? { + do { + return try ConfigurationManager.shared.exportConfiguration(watchConfig) + } catch { + Current.Log.error("Failed to export Watch configuration: \(error.localizedDescription)") + showError(message: "Failed to export configuration: \(error.localizedDescription)") + return nil + } + } + + @MainActor + func importConfiguration(from url: URL, completion: @escaping (Bool) -> Void) { + ConfigurationManager.shared.importConfiguration(from: url) { [weak self] result in + guard let self else { return } + + switch result { + case .success: + loadDatabase() + completion(true) + case let .failure(error): + Current.Log.error("Failed to import Watch configuration: \(error.localizedDescription)") + showError(message: "Failed to import configuration: \(error.localizedDescription)") + completion(false) + } + } + } } diff --git a/Sources/App/Settings/CarPlay/CarPlayConfig.swift b/Sources/App/Settings/CarPlay/CarPlayConfig.swift index a047197238..fc2cedd259 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfig.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfig.swift @@ -1,7 +1,7 @@ import Foundation import GRDB -public struct CarPlayConfig: Codable, FetchableRecord, PersistableRecord, Equatable { +public struct CarPlayConfig: Codable, FetchableRecord, PersistableRecord, Equatable, ConfigurationExportable { public static var carPlayConfigId = "carplay-config" public var id = CarPlayConfig.carPlayConfigId public var tabs: [CarPlayTab] = [.quickAccess, .areas, .domains, .settings] @@ -21,6 +21,10 @@ public struct CarPlayConfig: Codable, FetchableRecord, PersistableRecord, Equata try CarPlayConfig.fetchOne(db) }) } + + // MARK: - ConfigurationExportable + + public static var configurationType: ConfigurationType { .carPlay } } public enum CarPlayTab: String, Codable, CaseIterable, DatabaseValueConvertible, Equatable { diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift index ea2df904d2..0bf95e1d83 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift @@ -151,18 +151,7 @@ final class CarPlayConfigurationViewModel: ObservableObject { func exportConfiguration() -> URL? { do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(config) - - let tempDirectory = FileManager.default.temporaryDirectory - let fileName = "CarPlay.homeassistant" - let fileURL = tempDirectory.appendingPathComponent(fileName) - - try data.write(to: fileURL) - Current.Log.info("CarPlay configuration exported to \(fileURL.path)") - - return fileURL + return try ConfigurationManager.shared.exportConfiguration(config) } catch { Current.Log.error("Failed to export CarPlay configuration: \(error.localizedDescription)") showError(message: L10n.CarPlay.Export.Error.message(error.localizedDescription)) @@ -172,42 +161,19 @@ final class CarPlayConfigurationViewModel: ObservableObject { @MainActor func importConfiguration(from url: URL, completion: @escaping (Bool) -> Void) { - do { - guard url.startAccessingSecurityScopedResource() else { - showError(message: L10n.CarPlay.Import.Error.invalidFile) + ConfigurationManager.shared.importConfiguration(from: url) { [weak self] result in + guard let self else { return } + + switch result { + case .success: + loadDatabase() + completion(true) + case let .failure(error): + Current.Log.error("Failed to import CarPlay configuration: \(error.localizedDescription)") + showError(message: L10n.CarPlay.Import.Error.message(error.localizedDescription)) completion(false) - return - } - - defer { - url.stopAccessingSecurityScopedResource() - } - - let data = try Data(contentsOf: url) - let decoder = JSONDecoder() - var importedConfig = try decoder.decode(CarPlayConfig.self, from: data) - - // Migrate items to match current server IDs - importedConfig.quickAccessItems = magicItemProvider.migrateItemsIfNeeded(items: importedConfig.quickAccessItems) - - // Update configuration - setConfig(importedConfig) - - // Save to database - save { success in - if success { - Current.Log.info("CarPlay configuration imported successfully") - completion(true) - } else { - Current.Log.error("Failed to save imported configuration") - showError(message: L10n.CarPlay.Import.Error.message("Failed to save configuration")) - completion(false) - } } - } catch { - Current.Log.error("Failed to import CarPlay configuration: \(error.localizedDescription)") - showError(message: L10n.CarPlay.Import.Error.message(error.localizedDescription)) - completion(false) } } + } } diff --git a/Sources/App/WebView/IncomingURLHandler.swift b/Sources/App/WebView/IncomingURLHandler.swift index 8e00067299..9309d9e169 100644 --- a/Sources/App/WebView/IncomingURLHandler.swift +++ b/Sources/App/WebView/IncomingURLHandler.swift @@ -24,6 +24,7 @@ class IncomingURLHandler { case invite case createCustomWidget = "createcustomwidget" case camera + case importConfig = "import-config" } // swiftlint:disable cyclomatic_complexity @@ -76,16 +77,27 @@ class IncomingURLHandler { Current.Log.error("No server found for open camera URL: \(url)") return false } - Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) - .done { webViewController in - let view = WebRTCVideoPlayerView( - server: server, - cameraEntityId: entityId - ).embeddedInHostingController() - view.modalPresentationStyle = .overFullScreen - webViewController.present(view, animated: true) + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { webViewController in + let view = WebRTCVideoPlayerView( + server: server, + cameraEntityId: entityId + ).embeddedInHostingController() + view.modalPresentationStyle = .overFullScreen + webViewController.present(view, animated: true) + } + case .importConfig: + // homeassistant://import-config?url=file://... + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryParameters = components.queryItems, + let fileURLString = queryParameters.first(where: { $0.name == "url" })?.value, + let fileURL = URL(string: fileURLString) else { + Current.Log.error("Invalid import config URL: \(url)") + return false } - case .navigate: // homeassistant://navigate/lovelace/dashboard + + handleConfigurationImport(from: fileURL) + case .navigate: // homeassistant://navigate/lovelace/dashboard guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } @@ -700,4 +712,58 @@ extension IncomingURLHandler { api.HandleAction(actionID: actionID, source: source).cauterize() } + + private func handleConfigurationImport(from fileURL: URL) { + Task { @MainActor in + do { + // Detect configuration type + let data = try Data(contentsOf: fileURL) + let decoder = JSONDecoder() + let container = try decoder.decode(ConfigurationExport.self, from: data) + + // Show confirmation alert + let alert = UIAlertController( + title: "Import \(container.type.displayName) Configuration?", + message: "This will replace your current \(container.type.displayName) configuration. This action cannot be undone.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: L10n.noLabel, style: .cancel)) + alert.addAction(UIAlertAction(title: L10n.yesLabel, style: .destructive) { [weak self] _ in + self?.performConfigurationImport(from: fileURL, type: container.type) + }) + + windowController.window?.rootViewController?.present(alert, animated: true) + } catch { + Current.Log.error("Failed to read configuration file: \(error.localizedDescription)") + showAlert( + title: L10n.errorLabel, + message: "Failed to read configuration file: \(error.localizedDescription)" + ) + } + } + } + + private func performConfigurationImport(from fileURL: URL, type: ConfigurationType) { + Task { @MainActor in + ConfigurationManager.shared.importConfiguration(from: fileURL) { [weak self] result in + guard let self else { return } + + switch result { + case .success(let importedType): + Current.Log.info("\(importedType.displayName) configuration imported successfully") + showAlert( + title: "Success", + message: "\(importedType.displayName) configuration imported successfully" + ) + case .failure(let error): + Current.Log.error("Failed to import configuration: \(error.localizedDescription)") + showAlert( + title: L10n.errorLabel, + message: error.localizedDescription + ) + } + } + } + } } diff --git a/Sources/Shared/Config/ConfigurationExportable.swift b/Sources/Shared/Config/ConfigurationExportable.swift new file mode 100644 index 0000000000..4420ac7dcf --- /dev/null +++ b/Sources/Shared/Config/ConfigurationExportable.swift @@ -0,0 +1,151 @@ +import Foundation +import GRDB + +// MARK: - Configuration Export/Import Infrastructure + +/// Version of the configuration export format +public enum ConfigurationExportVersion: Int, Codable { + case v1 = 1 + + public static var current: ConfigurationExportVersion { .v1 } +} + +/// Types of configurations that can be exported/imported +public enum ConfigurationType: String, Codable { + case carPlay = "carplay" + case watch = "watch" + case widgets = "widgets" + + public var displayName: String { + switch self { + case .carPlay: + return "CarPlay" + case .watch: + return "Apple Watch" + case .widgets: + return "Widgets" + } + } + + public var fileExtension: String { + "homeassistant" + } + + public func fileName(version: ConfigurationExportVersion = .current) -> String { + "HomeAssistant-\(displayName)-v\(version.rawValue).\(fileExtension)" + } +} + +/// Container for exported configuration data +public struct ConfigurationExport: Codable { + public let version: ConfigurationExportVersion + public let type: ConfigurationType + public let exportDate: Date + public let data: Data + + public init(version: ConfigurationExportVersion, type: ConfigurationType, data: Data) { + self.version = version + self.type = type + self.exportDate = Date() + self.data = data + } +} + +/// Protocol for configurations that can be exported/imported +public protocol ConfigurationExportable: Codable, FetchableRecord, PersistableRecord { + /// The type of configuration + static var configurationType: ConfigurationType { get } + + /// Export the configuration to a file URL + func exportToFile() throws -> URL + + /// Import configuration from file URL + static func importFromFile(url: URL) throws -> Self +} + +public extension ConfigurationExportable { + func exportToFile() throws -> URL { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + // Encode the actual configuration + let configData = try encoder.encode(self) + + // Create export container + let exportContainer = ConfigurationExport( + version: .current, + type: Self.configurationType, + data: configData + ) + + // Encode the container + let containerData = try encoder.encode(exportContainer) + + // Write to temporary file + let tempDirectory = FileManager.default.temporaryDirectory + let fileName = Self.configurationType.fileName() + let fileURL = tempDirectory.appendingPathComponent(fileName) + + try containerData.write(to: fileURL) + Current.Log.info("Configuration exported to \(fileURL.path)") + + return fileURL + } + + static func importFromFile(url: URL) throws -> Self { + guard url.startAccessingSecurityScopedResource() else { + throw ConfigurationImportError.securityScopedResourceAccessFailed + } + + defer { + url.stopAccessingSecurityScopedResource() + } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + + // Decode the container + let container = try decoder.decode(ConfigurationExport.self, from: data) + + // Validate type + guard container.type == Self.configurationType else { + throw ConfigurationImportError.incorrectConfigurationType( + expected: Self.configurationType, + found: container.type + ) + } + + // Validate version + guard container.version == .current else { + throw ConfigurationImportError.unsupportedVersion(container.version) + } + + // Decode the actual configuration + let configuration = try decoder.decode(Self.self, from: container.data) + + Current.Log.info("Configuration imported from \(url.path)") + + return configuration + } +} + +/// Errors that can occur during configuration import +public enum ConfigurationImportError: LocalizedError { + case securityScopedResourceAccessFailed + case incorrectConfigurationType(expected: ConfigurationType, found: ConfigurationType) + case unsupportedVersion(ConfigurationExportVersion) + case invalidFileFormat + + public var errorDescription: String? { + switch self { + case .securityScopedResourceAccessFailed: + return "Failed to access file" + case let .incorrectConfigurationType(expected, found): + return "Incorrect configuration type. Expected \(expected.displayName), found \(found.displayName)" + case let .unsupportedVersion(version): + return "Unsupported configuration version: v\(version.rawValue)" + case .invalidFileFormat: + return "Invalid configuration file format" + } + } +} diff --git a/Sources/Shared/Config/ConfigurationManager.swift b/Sources/Shared/Config/ConfigurationManager.swift new file mode 100644 index 0000000000..70469c7ef9 --- /dev/null +++ b/Sources/Shared/Config/ConfigurationManager.swift @@ -0,0 +1,120 @@ +import Foundation +import GRDB +import PromiseKit + +/// Central manager for configuration export/import operations +public final class ConfigurationManager { + public static let shared = ConfigurationManager() + + private init() {} + + // MARK: - Export + + /// Export a configuration to a shareable file + public func exportConfiguration(_ config: T) throws -> URL { + try config.exportToFile() + } + + // MARK: - Import + + /// Import a configuration from a file URL with migration and confirmation + @MainActor + public func importConfiguration( + from url: URL, + completion: @escaping (Result) -> Void + ) { + do { + // Read file to determine type + let configType = try detectConfigurationType(from: url) + + switch configType { + case .carPlay: + try importCarPlayConfiguration(from: url, completion: completion) + case .watch: + try importWatchConfiguration(from: url, completion: completion) + case .widgets: + try importWidgetsConfiguration(from: url, completion: completion) + } + } catch { + Current.Log.error("Failed to import configuration: \(error.localizedDescription)") + completion(.failure(error)) + } + } + + // MARK: - Private Import Helpers + + @MainActor + private func importCarPlayConfiguration( + from url: URL, + completion: @escaping (Result) -> Void + ) throws { + var config = try CarPlayConfig.importFromFile(url: url) + + // Migrate items to match current server IDs + config.quickAccessItems = Current.magicItemProvider().migrateItemsIfNeeded(items: config.quickAccessItems) + + // Save to database + try Current.database().write { db in + try config.insert(db, onConflict: .replace) + } + + Current.Log.info("CarPlay configuration imported successfully") + completion(.success(.carPlay)) + } + + @MainActor + private func importWatchConfiguration( + from url: URL, + completion: @escaping (Result) -> Void + ) throws { + var config = try WatchConfig.importFromFile(url: url) + + // Migrate items to match current server IDs + config.items = Current.magicItemProvider().migrateItemsIfNeeded(items: config.items) + + // Save to database + try Current.database().write { db in + try config.insert(db, onConflict: .replace) + } + + Current.Log.info("Watch configuration imported successfully") + completion(.success(.watch)) + } + + @MainActor + private func importWidgetsConfiguration( + from url: URL, + completion: @escaping (Result) -> Void + ) throws { + var config = try CustomWidget.importFromFile(url: url) + + // Migrate items to match current server IDs + config.items = Current.magicItemProvider().migrateItemsIfNeeded(items: config.items) + + // Save to database + try Current.database().write { db in + try config.insert(db, onConflict: .replace) + } + + Current.Log.info("Widget configuration imported successfully") + completion(.success(.widgets)) + } + + // MARK: - Type Detection + + private func detectConfigurationType(from url: URL) throws -> ConfigurationType { + guard url.startAccessingSecurityScopedResource() else { + throw ConfigurationImportError.securityScopedResourceAccessFailed + } + + defer { + url.stopAccessingSecurityScopedResource() + } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + let container = try decoder.decode(ConfigurationExport.self, from: data) + + return container.type + } +} diff --git a/Sources/Shared/Watch/WatchConfig.swift b/Sources/Shared/Watch/WatchConfig.swift index d92e7d90d7..dd0799ace9 100644 --- a/Sources/Shared/Watch/WatchConfig.swift +++ b/Sources/Shared/Watch/WatchConfig.swift @@ -1,7 +1,7 @@ import Foundation import GRDB -public struct WatchConfig: WatchCodable, FetchableRecord, PersistableRecord { +public struct WatchConfig: WatchCodable, FetchableRecord, PersistableRecord, ConfigurationExportable { public static var watchConfigId: String { "watch-config" } public var id = WatchConfig.watchConfigId public var assist: Assist = .init(showAssist: true) @@ -34,6 +34,10 @@ public struct WatchConfig: WatchCodable, FetchableRecord, PersistableRecord { try WatchConfig.fetchOne(db) }) } + + // MARK: - ConfigurationExportable + + public static var configurationType: ConfigurationType { .watch } } public protocol WatchCodable: Codable { diff --git a/Sources/Shared/Widget/CustomWidget.swift b/Sources/Shared/Widget/CustomWidget.swift index 8da9ab6be6..af1044b0ee 100644 --- a/Sources/Shared/Widget/CustomWidget.swift +++ b/Sources/Shared/Widget/CustomWidget.swift @@ -1,7 +1,7 @@ import Foundation import GRDB -public struct CustomWidget: Codable, FetchableRecord, PersistableRecord, Equatable { +public struct CustomWidget: Codable, FetchableRecord, PersistableRecord, Equatable, ConfigurationExportable { public var id: String public var name: String public var items: [MagicItem] @@ -29,4 +29,8 @@ public struct CustomWidget: Codable, FetchableRecord, PersistableRecord, Equatab try CustomWidget.fetchAll(db) }) } + + // MARK: - ConfigurationExportable + + public static var configurationType: ConfigurationType { .widgets } } From 55234b2d42bc6fc44e8e136ce2b1350865381d98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:17:43 +0000 Subject: [PATCH 6/8] Fix code review issues: remove extra brace, rename method, add localization - Fixed extra closing brace in CarPlayConfigurationViewModel - Renamed importWidgetsConfiguration to importWidgetConfiguration (singular) - Added localization strings for import confirmation UI - Updated IncomingURLHandler to use L10n instead of hardcoded strings - Added Config.Import localization enum with success and confirmation messages Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> --- .../Resources/en.lproj/Localizable.strings | 4 ++++ .../CarPlayConfigurationViewModel.swift | 1 - Sources/App/WebView/IncomingURLHandler.swift | 8 +++---- .../Shared/Config/ConfigurationManager.swift | 4 ++-- .../Shared/Resources/Swiftgen/Strings.swift | 23 +++++++++++++++++++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 839e5f3aa0..427a02017e 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -127,6 +127,10 @@ "carPlay.import.error.message" = "Failed to import configuration: %@"; "carPlay.import.error.invalid_file" = "Invalid configuration file"; "carPlay.import.success.message" = "Configuration imported successfully"; +"config.import.confirmation.title" = "Import %@ Configuration?"; +"config.import.confirmation.message" = "This will replace your current %@ configuration. This action cannot be undone."; +"config.import.success.title" = "Success"; +"config.import.success.message" = "%@ configuration imported successfully"; "carPlay.labels.already_added_server" = "Already added"; "carPlay.labels.empty_domain_list" = "No domains available"; "carPlay.labels.no_servers_available" = "No servers available. Add a server in the app."; diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift index 0bf95e1d83..f52ebe57aa 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift @@ -175,5 +175,4 @@ final class CarPlayConfigurationViewModel: ObservableObject { } } } - } } diff --git a/Sources/App/WebView/IncomingURLHandler.swift b/Sources/App/WebView/IncomingURLHandler.swift index 9309d9e169..d15a952b83 100644 --- a/Sources/App/WebView/IncomingURLHandler.swift +++ b/Sources/App/WebView/IncomingURLHandler.swift @@ -723,8 +723,8 @@ extension IncomingURLHandler { // Show confirmation alert let alert = UIAlertController( - title: "Import \(container.type.displayName) Configuration?", - message: "This will replace your current \(container.type.displayName) configuration. This action cannot be undone.", + title: L10n.Config.Import.Confirmation.title(container.type.displayName), + message: L10n.Config.Import.Confirmation.message(container.type.displayName), preferredStyle: .alert ) @@ -753,8 +753,8 @@ extension IncomingURLHandler { case .success(let importedType): Current.Log.info("\(importedType.displayName) configuration imported successfully") showAlert( - title: "Success", - message: "\(importedType.displayName) configuration imported successfully" + title: L10n.Config.Import.Success.title, + message: L10n.Config.Import.Success.message(importedType.displayName) ) case .failure(let error): Current.Log.error("Failed to import configuration: \(error.localizedDescription)") diff --git a/Sources/Shared/Config/ConfigurationManager.swift b/Sources/Shared/Config/ConfigurationManager.swift index 70469c7ef9..8d9c3552d0 100644 --- a/Sources/Shared/Config/ConfigurationManager.swift +++ b/Sources/Shared/Config/ConfigurationManager.swift @@ -33,7 +33,7 @@ public final class ConfigurationManager { case .watch: try importWatchConfiguration(from: url, completion: completion) case .widgets: - try importWidgetsConfiguration(from: url, completion: completion) + try importWidgetConfiguration(from: url, completion: completion) } } catch { Current.Log.error("Failed to import configuration: \(error.localizedDescription)") @@ -82,7 +82,7 @@ public final class ConfigurationManager { } @MainActor - private func importWidgetsConfiguration( + private func importWidgetConfiguration( from url: URL, completion: @escaping (Result) -> Void ) throws { diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 5de3eb7427..7a87739f36 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -752,6 +752,29 @@ public enum L10n { } } + public enum Config { + public enum Import { + public enum Confirmation { + /// This will replace your current %@ configuration. This action cannot be undone. + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "config.import.confirmation.message", String(describing: p1)) + } + /// Import %@ Configuration? + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "config.import.confirmation.title", String(describing: p1)) + } + } + public enum Success { + /// %@ configuration imported successfully + public static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "config.import.success.message", String(describing: p1)) + } + /// Success + public static var title: String { return L10n.tr("Localizable", "config.import.success.title") } + } + } + } + public enum ClError { public enum Description { /// Deferred mode is not supported for the requested accuracy. From 99918a00ec771e480091175cbb3f8932092a5b7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:18:47 +0000 Subject: [PATCH 7/8] Add tests for versioned export format and configuration types - Added test for ConfigurationExport container format - Added test for versioned filenames (HomeAssistant-{Type}-v1.homeassistant) - Verify version, type, and data encoding/decoding - Test all three configuration types (CarPlay, Watch, Widgets) Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> --- .../CarPlayConfigExportImport.test.swift | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift b/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift index 83c7acb588..3c23891917 100644 --- a/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift +++ b/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift @@ -55,12 +55,63 @@ struct CarPlayConfigExportImportTests { ) } - @Test func testCarPlayConfigFileExtension() throws { - let fileName = "CarPlay.homeassistant" - let tempDirectory = FileManager.default.temporaryDirectory - let fileURL = tempDirectory.appendingPathComponent(fileName) + @Test func testConfigurationExportFormat() throws { + // Create a test configuration + let config = CarPlayConfig( + id: "carplay-config", + tabs: [.quickAccess, .areas], + quickAccessItems: [ + .init( + id: "script.test", + serverId: "test-server", + type: .script + ), + ] + ) + + // Encode configuration + let configEncoder = JSONEncoder() + let configData = try configEncoder.encode(config) + + // Create export container + let exportContainer = ConfigurationExport( + version: .v1, + type: .carPlay, + data: configData + ) + + // Encode container + let containerEncoder = JSONEncoder() + containerEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let containerData = try containerEncoder.encode(exportContainer) - #expect(fileURL.pathExtension == "homeassistant", "File extension should be homeassistant") - #expect(fileURL.lastPathComponent == fileName, "File name should match") + #expect(containerData.count > 0, "Container data should not be empty") + + // Decode and verify + let decoder = JSONDecoder() + let decodedContainer = try decoder.decode(ConfigurationExport.self, from: containerData) + + #expect(decodedContainer.version == .v1, "Version should be v1") + #expect(decodedContainer.type == .carPlay, "Type should be carPlay") + #expect(decodedContainer.data.count > 0, "Config data should not be empty") + + // Decode the inner configuration + let decodedConfig = try decoder.decode(CarPlayConfig.self, from: decodedContainer.data) + #expect(decodedConfig.id == config.id, "Config ID should match") + #expect(decodedConfig.tabs == config.tabs, "Tabs should match") + } + + @Test func testConfigurationTypeFileName() throws { + let carPlayFileName = ConfigurationType.carPlay.fileName() + #expect(carPlayFileName == "HomeAssistant-CarPlay-v1.homeassistant", "CarPlay filename should be versioned") + + let watchFileName = ConfigurationType.watch.fileName() + #expect(watchFileName == "HomeAssistant-Apple Watch-v1.homeassistant", "Watch filename should be versioned") + + let widgetsFileName = ConfigurationType.widgets.fileName() + #expect( + widgetsFileName == "HomeAssistant-Widgets-v1.homeassistant", + "Widgets filename should be versioned" + ) } } From f7476f04f0e5bc44013a92a6ac96201fad2a3e59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:49:05 +0000 Subject: [PATCH 8/8] Fix linting issues: remove trailing spaces, fix indentation, use opaque generics - Removed all trailing spaces from ConfigurationExportable.swift - Fixed indentation in IncomingURLHandler.swift (camera case) - Changed generic parameter to opaque generic (some ConfigurationExportable) - Fixed hoistPatternLet warnings (repositioned let bindings in switch cases) - Removed trailing spaces from ViewModel files Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> --- .../WatchConfigurationViewModel.swift | 2 +- .../CarPlayConfigurationViewModel.swift | 2 +- Sources/App/WebView/IncomingURLHandler.swift | 52 +++++++++---------- .../Config/ConfigurationExportable.swift | 46 ++++++++-------- .../Shared/Config/ConfigurationManager.swift | 2 +- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift index 1eb3036ec7..8d2210a53f 100644 --- a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift +++ b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationViewModel.swift @@ -146,7 +146,7 @@ final class WatchConfigurationViewModel: ObservableObject { func importConfiguration(from url: URL, completion: @escaping (Bool) -> Void) { ConfigurationManager.shared.importConfiguration(from: url) { [weak self] result in guard let self else { return } - + switch result { case .success: loadDatabase() diff --git a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift index f52ebe57aa..fdb1cc9de8 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift @@ -163,7 +163,7 @@ final class CarPlayConfigurationViewModel: ObservableObject { func importConfiguration(from url: URL, completion: @escaping (Bool) -> Void) { ConfigurationManager.shared.importConfiguration(from: url) { [weak self] result in guard let self else { return } - + switch result { case .success: loadDatabase() diff --git a/Sources/App/WebView/IncomingURLHandler.swift b/Sources/App/WebView/IncomingURLHandler.swift index d15a952b83..fc734e8f46 100644 --- a/Sources/App/WebView/IncomingURLHandler.swift +++ b/Sources/App/WebView/IncomingURLHandler.swift @@ -77,27 +77,27 @@ class IncomingURLHandler { Current.Log.error("No server found for open camera URL: \(url)") return false } - Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) - .done { webViewController in - let view = WebRTCVideoPlayerView( - server: server, - cameraEntityId: entityId - ).embeddedInHostingController() - view.modalPresentationStyle = .overFullScreen - webViewController.present(view, animated: true) - } - case .importConfig: - // homeassistant://import-config?url=file://... - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryParameters = components.queryItems, - let fileURLString = queryParameters.first(where: { $0.name == "url" })?.value, - let fileURL = URL(string: fileURLString) else { - Current.Log.error("Invalid import config URL: \(url)") - return false + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { webViewController in + let view = WebRTCVideoPlayerView( + server: server, + cameraEntityId: entityId + ).embeddedInHostingController() + view.modalPresentationStyle = .overFullScreen + webViewController.present(view, animated: true) } - - handleConfigurationImport(from: fileURL) - case .navigate: // homeassistant://navigate/lovelace/dashboard + case .importConfig: + // homeassistant://import-config?url=file://... + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryParameters = components.queryItems, + let fileURLString = queryParameters.first(where: { $0.name == "url" })?.value, + let fileURL = URL(string: fileURLString) else { + Current.Log.error("Invalid import config URL: \(url)") + return false + } + + handleConfigurationImport(from: fileURL) + case .navigate: // homeassistant://navigate/lovelace/dashboard guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } @@ -720,19 +720,19 @@ extension IncomingURLHandler { let data = try Data(contentsOf: fileURL) let decoder = JSONDecoder() let container = try decoder.decode(ConfigurationExport.self, from: data) - + // Show confirmation alert let alert = UIAlertController( title: L10n.Config.Import.Confirmation.title(container.type.displayName), message: L10n.Config.Import.Confirmation.message(container.type.displayName), preferredStyle: .alert ) - + alert.addAction(UIAlertAction(title: L10n.noLabel, style: .cancel)) alert.addAction(UIAlertAction(title: L10n.yesLabel, style: .destructive) { [weak self] _ in self?.performConfigurationImport(from: fileURL, type: container.type) }) - + windowController.window?.rootViewController?.present(alert, animated: true) } catch { Current.Log.error("Failed to read configuration file: \(error.localizedDescription)") @@ -748,15 +748,15 @@ extension IncomingURLHandler { Task { @MainActor in ConfigurationManager.shared.importConfiguration(from: fileURL) { [weak self] result in guard let self else { return } - + switch result { - case .success(let importedType): + case let .success(importedType): Current.Log.info("\(importedType.displayName) configuration imported successfully") showAlert( title: L10n.Config.Import.Success.title, message: L10n.Config.Import.Success.message(importedType.displayName) ) - case .failure(let error): + case let .failure(error): Current.Log.error("Failed to import configuration: \(error.localizedDescription)") showAlert( title: L10n.errorLabel, diff --git a/Sources/Shared/Config/ConfigurationExportable.swift b/Sources/Shared/Config/ConfigurationExportable.swift index 4420ac7dcf..dfae171ffc 100644 --- a/Sources/Shared/Config/ConfigurationExportable.swift +++ b/Sources/Shared/Config/ConfigurationExportable.swift @@ -6,7 +6,7 @@ import GRDB /// Version of the configuration export format public enum ConfigurationExportVersion: Int, Codable { case v1 = 1 - + public static var current: ConfigurationExportVersion { .v1 } } @@ -15,7 +15,7 @@ public enum ConfigurationType: String, Codable { case carPlay = "carplay" case watch = "watch" case widgets = "widgets" - + public var displayName: String { switch self { case .carPlay: @@ -26,11 +26,11 @@ public enum ConfigurationType: String, Codable { return "Widgets" } } - + public var fileExtension: String { "homeassistant" } - + public func fileName(version: ConfigurationExportVersion = .current) -> String { "HomeAssistant-\(displayName)-v\(version.rawValue).\(fileExtension)" } @@ -42,7 +42,7 @@ public struct ConfigurationExport: Codable { public let type: ConfigurationType public let exportDate: Date public let data: Data - + public init(version: ConfigurationExportVersion, type: ConfigurationType, data: Data) { self.version = version self.type = type @@ -55,10 +55,10 @@ public struct ConfigurationExport: Codable { public protocol ConfigurationExportable: Codable, FetchableRecord, PersistableRecord { /// The type of configuration static var configurationType: ConfigurationType { get } - + /// Export the configuration to a file URL func exportToFile() throws -> URL - + /// Import configuration from file URL static func importFromFile(url: URL) throws -> Self } @@ -67,46 +67,46 @@ public extension ConfigurationExportable { func exportToFile() throws -> URL { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - + // Encode the actual configuration let configData = try encoder.encode(self) - + // Create export container let exportContainer = ConfigurationExport( version: .current, type: Self.configurationType, data: configData ) - + // Encode the container let containerData = try encoder.encode(exportContainer) - + // Write to temporary file let tempDirectory = FileManager.default.temporaryDirectory let fileName = Self.configurationType.fileName() let fileURL = tempDirectory.appendingPathComponent(fileName) - + try containerData.write(to: fileURL) Current.Log.info("Configuration exported to \(fileURL.path)") - + return fileURL } - + static func importFromFile(url: URL) throws -> Self { guard url.startAccessingSecurityScopedResource() else { throw ConfigurationImportError.securityScopedResourceAccessFailed } - + defer { url.stopAccessingSecurityScopedResource() } - + let data = try Data(contentsOf: url) let decoder = JSONDecoder() - + // Decode the container let container = try decoder.decode(ConfigurationExport.self, from: data) - + // Validate type guard container.type == Self.configurationType else { throw ConfigurationImportError.incorrectConfigurationType( @@ -114,17 +114,17 @@ public extension ConfigurationExportable { found: container.type ) } - + // Validate version guard container.version == .current else { throw ConfigurationImportError.unsupportedVersion(container.version) } - + // Decode the actual configuration let configuration = try decoder.decode(Self.self, from: container.data) - + Current.Log.info("Configuration imported from \(url.path)") - + return configuration } } @@ -135,7 +135,7 @@ public enum ConfigurationImportError: LocalizedError { case incorrectConfigurationType(expected: ConfigurationType, found: ConfigurationType) case unsupportedVersion(ConfigurationExportVersion) case invalidFileFormat - + public var errorDescription: String? { switch self { case .securityScopedResourceAccessFailed: diff --git a/Sources/Shared/Config/ConfigurationManager.swift b/Sources/Shared/Config/ConfigurationManager.swift index 8d9c3552d0..7d218d5f65 100644 --- a/Sources/Shared/Config/ConfigurationManager.swift +++ b/Sources/Shared/Config/ConfigurationManager.swift @@ -11,7 +11,7 @@ public final class ConfigurationManager { // MARK: - Export /// Export a configuration to a shareable file - public func exportConfiguration(_ config: T) throws -> URL { + public func exportConfiguration(_ config: some ConfigurationExportable) throws -> URL { try config.exportToFile() }