Skip to content
12 changes: 12 additions & 0 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import SFSafeSymbols
import Shared
import StoreKit
import SwiftUI
import UniformTypeIdentifiers

struct WatchConfigurationView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = WatchConfigurationViewModel()

@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
Expand Down Expand Up @@ -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 {
Expand All @@ -56,6 +97,7 @@ struct WatchConfigurationView: View {
}
itemsSection
assistSection
exportImportSection
resetView
}
.preferredColorScheme(.dark)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
6 changes: 5 additions & 1 deletion Sources/App/Settings/CarPlay/CarPlayConfig.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import GRDB

public struct CarPlayConfig: Codable, FetchableRecord, PersistableRecord, Equatable {
public struct CarPlayConfig: Codable, FetchableRecord, PersistableRecord, Equatable, ConfigurationExportable {

Check failure on line 4 in Sources/App/Settings/CarPlay/CarPlayConfig.swift

View workflow job for this annotation

GitHub Actions / test

cannot find type 'ConfigurationExportable' in scope

Check failure on line 4 in Sources/App/Settings/CarPlay/CarPlayConfig.swift

View workflow job for this annotation

GitHub Actions / test

cannot find type 'ConfigurationExportable' in scope
public static var carPlayConfigId = "carplay-config"
public var id = CarPlayConfig.carPlayConfigId
public var tabs: [CarPlayTab] = [.quickAccess, .areas, .domains, .settings]
Expand All @@ -21,6 +21,10 @@
try CarPlayConfig.fetchOne(db)
})
}

// MARK: - ConfigurationExportable

public static var configurationType: ConfigurationType { .carPlay }

Check failure on line 27 in Sources/App/Settings/CarPlay/CarPlayConfig.swift

View workflow job for this annotation

GitHub Actions / test

cannot find type 'ConfigurationType' in scope

Check failure on line 27 in Sources/App/Settings/CarPlay/CarPlayConfig.swift

View workflow job for this annotation

GitHub Actions / test

cannot find type 'ConfigurationType' in scope
}

public enum CarPlayTab: String, Codable, CaseIterable, DatabaseValueConvertible, Equatable {
Expand Down
61 changes: 61 additions & 0 deletions Sources/App/Settings/CarPlay/CarPlayConfigurationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import SFSafeSymbols
import Shared
import StoreKit
import SwiftUI
import UniformTypeIdentifiers

struct CarPlayConfigurationView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = CarPlayConfigurationViewModel()

@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
Expand Down Expand Up @@ -48,13 +54,49 @@ 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 {
List {
carPlayLogo
tabsSection
itemsSection
exportImportSection
resetView
}
}
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions Sources/App/Settings/CarPlay/CarPlayConfigurationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
66 changes: 66 additions & 0 deletions Sources/App/WebView/IncomingURLHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class IncomingURLHandler {
case invite
case createCustomWidget = "createcustomwidget"
case camera
case importConfig = "import-config"
}

// swiftlint:disable cyclomatic_complexity
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
}
}
}
}
Loading
Loading