diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 2c40ce1598..427a02017e 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -119,6 +119,18 @@ "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"; +"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/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..8d2210a53f 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/CarPlayConfigurationView.swift b/Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift index d3ad45299b..93dc3ddf0d 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") ?? .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.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..fdb1cc9de8 100644 --- a/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift +++ b/Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift @@ -146,4 +146,33 @@ 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 { + 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)) + 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 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..fc734e8f46 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 @@ -85,6 +86,17 @@ class IncomingURLHandler { 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 + } + + 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: 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)") + 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 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 let .failure(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..dfae171ffc --- /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..7d218d5f65 --- /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: some ConfigurationExportable) 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 importWidgetConfiguration(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 importWidgetConfiguration( + 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/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/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 595e966d16..7a87739f36 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") } @@ -716,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. 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 } } diff --git a/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift b/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift new file mode 100644 index 0000000000..3c23891917 --- /dev/null +++ b/Tests/App/CarPlay/CarPlayConfigExportImport.test.swift @@ -0,0 +1,117 @@ +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 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(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" + ) + } +}