diff --git a/wled/Localizable.xcstrings b/wled/Localizable.xcstrings index cd1c150..1d90b83 100644 --- a/wled/Localizable.xcstrings +++ b/wled/Localizable.xcstrings @@ -411,6 +411,16 @@ } } }, + "Device Added" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device Added" + } + } + } + }, "Device List" : { "comment" : "The title of the device list screen.", "isCommentAutoGenerated" : true, @@ -698,6 +708,57 @@ } } }, + "Local Network Access Required" : { + "comment" : "Title of the warning banner that appears when the local network permission has been denied.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local Network Access Required" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accès au réseau local requis" + } + } + } + }, + "local_network_instructions" : { + "comment" : "Step-by-step instructions for enabling local network permission in iOS Settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to Settings → WLED → Local Network" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aller dans Réglages → WLED → Réseau local" + } + } + } + }, + "local_network_warning_body" : { + "comment" : "Body text of the warning banner explaining why local network access is needed.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WLED needs Local Network access to discover and control your devices. Please enable it in Settings." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "WLED a besoin de l'accès au réseau local pour découvrir et contrôler vos appareils. Veuillez l'activer dans les Réglages." + } + } + } + }, "Mac Address: %@" : { "comment" : "A label displaying the MAC address of a device.", "isCommentAutoGenerated" : true, @@ -857,6 +918,23 @@ } } }, + "Open Settings" : { + "comment" : "Button label that opens the app's Settings page so the user can enable Local Network permission.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les Réglages" + } + } + } + }, "Please do not close the app or turn off the device." : { "comment" : "Additional instructions to show to the user while a software update is downloading.", "isCommentAutoGenerated" : true, @@ -1422,6 +1500,17 @@ } } }, + "Use Secure Connections" : { + "comment" : "Toggle switch text for when https/wss should be used", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use Secure Connections" + } + } + } + }, "Version %@" : { "comment" : "A label displaying the current version of the app on a device. The argument is the current version of the app on the device.", "isCommentAutoGenerated" : true, @@ -1494,104 +1583,47 @@ } } }, - "You don't have any visible devices" : { - "comment" : "A label displayed when a user has no visible devices.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You don't have any visible devices" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vous n’avez aucun appareil visible" - } - } - } - }, - "Your device is up to date" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your device is up to date" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre appareil est à jour" - } - } - } - }, - "Local Network Access Required" : { - "comment" : "Title of the warning banner that appears when the local network permission has been denied.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Local Network Access Required" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accès au réseau local requis" - } - } - } - }, - "local_network_warning_body" : { - "comment" : "Body text of the warning banner explaining why local network access is needed.", + "WLED only supports HTTPS when accessed through a secure reverse proxy." : { + "comment" : "Hint text displaying warning that WLED itself does not support https, it is for when accessing through a secure reverse proxy.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "WLED needs Local Network access to discover and control your devices. Please enable it in Settings." - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "WLED a besoin de l'accès au réseau local pour découvrir et contrôler vos appareils. Veuillez l'activer dans les Réglages." + "value" : "WLED only supports HTTPS when accessed through a secure reverse proxy." } } } }, - "local_network_instructions" : { - "comment" : "Step-by-step instructions for enabling local network permission in iOS Settings.", + "You don't have any visible devices" : { + "comment" : "A label displayed when a user has no visible devices.", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Go to Settings → WLED → Local Network" + "value" : "You don't have any visible devices" } }, "fr-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Aller dans Réglages → WLED → Réseau local" + "value" : "Vous n’avez aucun appareil visible" } } } }, - "Open Settings" : { - "comment" : "Button label that opens the app's Settings page so the user can enable Local Network permission.", + "Your device is up to date" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open Settings" + "value" : "Your device is up to date" } }, "fr-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Ouvrir les Réglages" + "value" : "Votre appareil est à jour" } } } diff --git a/wled/Service/DeviceApi/DeviceExtensions.swift b/wled/Service/DeviceApi/DeviceExtensions.swift index bc6e77e..748f2a6 100644 --- a/wled/Service/DeviceApi/DeviceExtensions.swift +++ b/wled/Service/DeviceApi/DeviceExtensions.swift @@ -1,5 +1,39 @@ import Foundation +enum DeviceAddressNormalizer { + static func normalizedAddress(from address: String, defaultScheme: String = "http") -> String? { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let lowercasedAddress = trimmed.lowercased() + let rawAddress: String + + if lowercasedAddress.hasPrefix("http://") || lowercasedAddress.hasPrefix("https://") { + rawAddress = trimmed + } else if trimmed.contains("://") { + return nil + } else { + rawAddress = "\(defaultScheme)://\(trimmed)" + } + + guard var components = URLComponents(string: rawAddress), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host?.isEmpty == false else { + return nil + } + + components.user = nil + components.password = nil + components.path = "" + components.query = nil + components.fragment = nil + components.scheme = scheme + + return components.url?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } +} + extension Device { var displayName: String { if let name = customName, !name.isEmpty { @@ -11,6 +45,28 @@ extension Device { return String(localized: "(New Device)") } + var url: URL? { + guard let address = address?.trimmingCharacters(in: .whitespacesAndNewlines), + !address.isEmpty else { + return nil + } + return URL(string: address) + } + + var webSocketURL: URL? { + guard let url = url, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + components.scheme = components.scheme?.lowercased() == "https" ? "wss" : "ws" + components.path = "/ws" + components.query = nil + components.fragment = nil + + return components.url + } + func getColor(state: WledState?) -> Int64 { guard let state = state, let colorInfo = state.segment?.first?.colors?.first, @@ -19,7 +75,7 @@ extension Device { // Return neutral Gray if any data is missing return 0x808080 } - + let red = Int64(Double(colorInfo[0]) + 0.5) let green = Int64(Double(colorInfo[1]) + 0.5) let blue = Int64(Double(colorInfo[2]) + 0.5) diff --git a/wled/Service/DeviceFirstContactService.swift b/wled/Service/DeviceFirstContactService.swift index 7646779..1e94798 100644 --- a/wled/Service/DeviceFirstContactService.swift +++ b/wled/Service/DeviceFirstContactService.swift @@ -12,16 +12,16 @@ import OSLog /// Service responsible for handling the first contact with a device. /// It fetches device info and handles the creation or update of the Device entity in Core Data. actor DeviceFirstContactService { - + private let persistenceController: PersistenceController private let urlSession: URLSession private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ca.cgagnier.wled-native", category: "DeviceFirstContactService") - + enum ServiceError: LocalizedError { case invalidURL case missingMacAddress case networkError(Error) - + var errorDescription: String? { switch self { case .invalidURL: @@ -33,7 +33,7 @@ actor DeviceFirstContactService { } } } - + /// - Parameters: /// - persistenceController: The Core Data controller. /// - urlSession: Injected session for testability (defaults to .shared). @@ -41,9 +41,9 @@ actor DeviceFirstContactService { self.persistenceController = persistenceController self.urlSession = urlSession } - + // MARK: - Public API - + /// Fetches device information using its address, then ensures a corresponding /// device record exists in the database (creating or updating its address /// as necessary). @@ -51,19 +51,21 @@ actor DeviceFirstContactService { /// - Parameter rawAddress: The network address input (e.g., "http://192.168.1.1/" or "wled.local"). /// - Returns: The NSManagedObjectID of the device (to be retrieved safely on the main thread). func fetchAndUpsertDevice(rawAddress: String) async throws -> NSManagedObjectID { - let cleanAddress = sanitize(address: rawAddress) - + guard let cleanAddress = DeviceAddressNormalizer.normalizedAddress(from: rawAddress) else { + throw ServiceError.invalidURL + } + logger.debug("Initiating contact with: \(cleanAddress)") let info = try await fetchDeviceInfo(address: cleanAddress) - + guard let macAddress = info.mac, !macAddress.isEmpty else { logger.error("Could not retrieve MAC address for device at \(cleanAddress)") throw ServiceError.missingMacAddress } - - return try await upsertDevice(macAddress: macAddress, hostname: cleanAddress, name: info.name) + + return try await upsertDevice(macAddress: macAddress, address: cleanAddress, name: info.name) } - + /// Attempts to identify and update a device using only the MAC address from mDNS/Discovery. /// This avoids a network call to the device if we already know who it is. /// @@ -73,24 +75,24 @@ actor DeviceFirstContactService { /// - Returns: true if the device was found and processed (updated or skipped), false otherwise. func tryUpdateAddress(macAddress: String?, address: String) async -> Bool { guard let macAddress, !macAddress.isEmpty else { return false } - + // Ensure the address provided by mDNS is clean before saving - let cleanAddress = sanitize(address: address) + guard let cleanAddress = DeviceAddressNormalizer.normalizedAddress(from: address) else { return false } let logger = self.logger - + return await persistenceController.container.performBackgroundTask { context in let request: NSFetchRequest = Device.fetchRequest() request.predicate = NSPredicate(format: "macAddress == %@", macAddress) request.fetchLimit = 1 - + guard let existingDevice = try? context.fetch(request).first else { return false } - - if existingDevice.address != address { + + if existingDevice.address != cleanAddress { logger.info("Fast update: IP changed for \(existingDevice.originalName ?? "Unknown") (\(macAddress))") existingDevice.address = cleanAddress - + do { try context.save() } catch { @@ -100,39 +102,22 @@ actor DeviceFirstContactService { return true } } - + // MARK: - Private Helpers - - /// Removes schemes (http/https) and trailing slashes to ensure we store a clean hostname/IP. - private func sanitize(address: String) -> String { - var result = address - - // Remove scheme if present - if let range = result.range(of: "://") { - result = String(result[range.upperBound...]) - } - - // Remove trailing slashes - while result.hasSuffix("/") { - result.removeLast() - } - - return result - } - + /// Fetches device information from the specified address. private func fetchDeviceInfo(address: String) async throws -> Info { - // Construct URL, ensuring http scheme and json/info path - let urlString = "http://\(address)/json/info" - - guard let url = URL(string: urlString) else { + guard let base = URL(string: address) else { throw ServiceError.invalidURL } - + let url = base + .appendingPathComponent("json") + .appendingPathComponent("info") + var request = URLRequest(url: url) request.timeoutInterval = 10 request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - + do { let (data, _) = try await urlSession.data(for: request) return try JSONDecoder().decode(Info.self, from: data) @@ -140,27 +125,27 @@ actor DeviceFirstContactService { throw ServiceError.networkError(error) } } - + /// Handles the Core Data logic to find, update, or create the device. - private func upsertDevice(macAddress: String, hostname: String, name: String?) async throws -> NSManagedObjectID { + private func upsertDevice(macAddress: String, address: String, name: String?) async throws -> NSManagedObjectID { let logger = self.logger return try await persistenceController.container.performBackgroundTask { context in context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - + let request: NSFetchRequest = Device.fetchRequest() request.predicate = NSPredicate(format: "macAddress == %@", macAddress) request.fetchLimit = 1 - + let device: Device - + if let existingDevice = try? context.fetch(request).first { // Check if updates are actually needed to minimize Core Data thrashing - if existingDevice.address == hostname && existingDevice.originalName == name { + if existingDevice.address == address && existingDevice.originalName == name { logger.debug("Device exists and is up to date: \(macAddress)") device = existingDevice } else { logger.debug("Updating existing device: \(macAddress)") - existingDevice.address = hostname + existingDevice.address = address existingDevice.originalName = name device = existingDevice } @@ -168,15 +153,15 @@ actor DeviceFirstContactService { logger.info("Creating new device: \(macAddress)") device = Device(context: context) device.macAddress = macAddress - device.address = hostname + device.address = address device.originalName = name device.isHidden = false } - + if context.hasChanges { try context.save() } - + return device.objectID } } diff --git a/wled/Service/DeviceUpdateService.swift b/wled/Service/DeviceUpdateService.swift index 590eec0..68b6cac 100644 --- a/wled/Service/DeviceUpdateService.swift +++ b/wled/Service/DeviceUpdateService.swift @@ -236,7 +236,7 @@ class DeviceUpdateService: ObservableObject { let directory = cacheUrl.appendingPathComponent(version.tagName ?? "unknown", isDirectory: true) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory.appendingPathExtension(asset?.name ?? "unknown") + return directory.appendingPathComponent(asset?.name ?? "unknown") } catch let writeError { print("error creating directory \(directory) : \(writeError)") return nil @@ -255,10 +255,10 @@ class DeviceUpdateService: ObservableObject { FileManager.default.fileExists(atPath: binaryURL.path) else { throw UpdateError.fileNotFound } - guard let deviceAddress = device.device.address, - let url = URL(string: "http://\(deviceAddress)/update") else { + guard let base = device.device.url else { throw UpdateError.invalidURL } + let url = base.appendingPathComponent("update") let boundary = "Boundary-\(UUID().uuidString)" var request = URLRequest(url: url) diff --git a/wled/Service/Websocket/WebsocketClient.swift b/wled/Service/Websocket/WebsocketClient.swift index 84b761d..70d44e2 100644 --- a/wled/Service/Websocket/WebsocketClient.swift +++ b/wled/Service/Websocket/WebsocketClient.swift @@ -56,13 +56,11 @@ class WebsocketClient: NSObject, ObservableObject, URLSessionWebSocketDelegate { // MARK: - Connection Logic func connect() { - if webSocketTask != nil || isConnecting { - print("\(tag): Already connected or connecting to \(deviceState.device.address ?? "nil")") + guard webSocketTask == nil && !isConnecting else { return } - - guard let address = deviceState.device.address, !address.isEmpty else { - print("\(tag): Device address is empty") + guard let wsURL = deviceState.device.webSocketURL else { + print("\(tag): Invalid WebSocket URL from \(deviceState.device.address ?? "")") return } @@ -72,15 +70,9 @@ class WebsocketClient: NSObject, ObservableObject, URLSessionWebSocketDelegate { DispatchQueue.main.async { self.deviceState.websocketStatus = .connecting } - - let urlString = "ws://\(address)/ws" - guard let url = URL(string: urlString) else { - print("\(tag): Invalid URL \(urlString)") - return - } - - print("\(tag): Connecting to \(address)") - let request = URLRequest(url: url, timeoutInterval: 10) + + print("\(tag): Connecting to \(wsURL.absoluteString)") + let request = URLRequest(url: wsURL, timeoutInterval: 10) webSocketTask = urlSession.webSocketTask(with: request) webSocketTask?.resume() @@ -95,6 +87,7 @@ class WebsocketClient: NSObject, ObservableObject, URLSessionWebSocketDelegate { webSocketTask?.cancel(with: .normalClosure, reason: Data("Client disconnected".utf8)) webSocketTask = nil + retryCount = 0 DispatchQueue.main.async { self.deviceState.websocketStatus = .disconnected @@ -240,6 +233,8 @@ class WebsocketClient: NSObject, ObservableObject, URLSessionWebSocketDelegate { let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "No reason" print("\(self.tag): WebSocket closing. Code: \(closeCode), reason: \(reasonString)") + self.webSocketTask = nil + self.isConnecting = false self.deviceState.websocketStatus = .disconnected if closeCode != .normalClosure && !self.isManuallyDisconnected { diff --git a/wled/View/DeviceAdd/DeviceAddView.swift b/wled/View/DeviceAdd/DeviceAddView.swift index 1ebab94..c4bdac4 100644 --- a/wled/View/DeviceAdd/DeviceAddView.swift +++ b/wled/View/DeviceAdd/DeviceAddView.swift @@ -1,41 +1,41 @@ import SwiftUI struct DeviceAddView: View { - @Environment(\.managedObjectContext) private var viewContext @Environment(\.dismiss) var dismiss - - @ObservedObject private var viewModel = DeviceAddViewModel() - + @StateObject private var viewModel = DeviceAddViewModel() + var body: some View { - NavigationView { - VStack { + NavigationStack { + Group { switch viewModel.currentStep { case .form(let errorMessage): DeviceAddStep1FormView( viewModel: viewModel, - errorMessage: errorMessage, + errorMessage: errorMessage ) case .adding: DeviceAddStep2LoadingView(address: viewModel.address) case .success(let device): DeviceAddStep3Success(device: device) } - Spacer() } - .padding() - .animation(.easeInOut, value: viewModel.currentStep) + .animation(.easeInOut, value: currentStepAnimationID) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel", systemImage: "xmark") { + Button { dismiss() + } label: { + Label("Cancel", systemImage: "xmark") } } if viewModel.currentStep.isForm { ToolbarItem(placement: .primaryAction) { - Button("Add", systemImage: "checkmark") { + Button { withAnimation { viewModel.submitCreateDevice() } + } label: { + Label("Add", systemImage: "checkmark") } } } @@ -43,51 +43,138 @@ struct DeviceAddView: View { .navigationTitle("New Device") .navigationBarTitleDisplayMode(.inline) } - .presentationDetents([.medium]) + .onChange(of: currentStepAnimationID) { newValue in + guard newValue == 2 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + dismiss() + } + } + } + + private var currentStepAnimationID: Int { + switch viewModel.currentStep { + case .form: + 0 + case .adding: + 1 + case .success: + 2 + } } } // MARK: - Step 1: Form struct DeviceAddStep1FormView: View { - @ObservedObject var viewModel: DeviceAddViewModel @FocusState private var focusedField: Field? let errorMessage: String - let state = DeviceAddViewModel.Step.self - + var body: some View { - VStack(alignment: .leading) { - Text("IP Address or URL") - TextField("IP Address or URL", text: $viewModel.address) - .keyboardType(.URL) - .submitLabel(.done) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .address) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke( - errorMessage.isEmpty ? Color.clear : Color.red - ) - ) - .onSubmit { - withAnimation { - viewModel.submitCreateDevice() - } + Form { + Section { + LabeledContent("Custom Name") { + TextField("Custom Name", text: $viewModel.customName) + .multilineTextAlignment(.trailing) + .textInputAutocapitalization(.words) + .autocorrectionDisabled(true) + .focused($focusedField, equals: .customName) + .submitLabel(.next) + .onSubmit { + focusedField = .address + } } + } + + Section { + TextField("IP Address or URL", text: $viewModel.address, axis: .vertical) + .lineLimit(1) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .onChange(of: viewModel.address) { newValue in + let lowercased = newValue.lowercased() + + if lowercased.hasPrefix("https://") { + if !viewModel.useSecure { + viewModel.useSecure = true + } + } else if lowercased.hasPrefix("http://") { + if viewModel.useSecure { + viewModel.useSecure = false + } + } + } + .focused($focusedField, equals: .address) + .submitLabel(.done) + .onSubmit { + normalizeAddressScheme() + withAnimation { + viewModel.submitCreateDevice() + } + } + + Toggle("Use Secure Connections", isOn: Binding( + get: { + viewModel.useSecure + }, + set: { newValue in + viewModel.useSecure = newValue + + let lowercased = viewModel.address.lowercased() + + if newValue && lowercased.hasPrefix("http://") { + viewModel.address.removeFirst("http://".count) + viewModel.address = "https://" + viewModel.address + } else if !newValue && lowercased.hasPrefix("https://") { + viewModel.address.removeFirst("https://".count) + viewModel.address = "http://" + viewModel.address + } + } + )) + } header: { + Text("IP Address or URL") + } footer: { + Text("WLED only supports HTTPS when accessed through a secure reverse proxy.") + } + if !errorMessage.isEmpty { - Text(errorMessage) - .foregroundStyle(.red) - .font(Font.caption.bold()) + Section { + Text(errorMessage) + .foregroundStyle(.red) + .font(.caption.bold()) + } } } .onAppear { - focusedField = .address + focusedField = .customName + } + .onChange(of: focusedField) { newValue in + if newValue != .address { + normalizeAddressScheme() + } } } - + + private func normalizeAddressScheme() { + let trimmedAddress = viewModel.address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedAddress.isEmpty else { + viewModel.address = "" + return + } + + let lowercased = trimmedAddress.lowercased() + guard !lowercased.hasPrefix("http://") && !lowercased.hasPrefix("https://") else { + viewModel.address = trimmedAddress + return + } + + viewModel.address = (viewModel.useSecure ? "https://" : "http://") + trimmedAddress + } + enum Field: Hashable { + case customName case address } } @@ -98,10 +185,15 @@ struct DeviceAddStep2LoadingView: View { let address: String var body: some View { - ProgressView() - .controlSize(ControlSize.large) - .padding() - Text("Adding \(address)") + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + + Text(String(format: NSLocalizedString("Adding %@", comment: ""), address)) + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -111,11 +203,21 @@ struct DeviceAddStep3Success: View { let device: Device var body: some View { - Image(systemName: "checkmark.seal") - .font(.system(size: 50)) - .foregroundStyle(.green) - .padding() - Text("\(device.displayName) was added") + VStack(spacing: 16) { + Image(systemName: "checkmark.seal") + .font(.system(size: 48)) + .foregroundStyle(.green) + + Text("Device Added") + .font(.title3.bold()) + + Text(String(format: NSLocalizedString("%@ was added", comment: ""), device.displayName)) + .font(.headline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/wled/View/DeviceAdd/DeviceAddViewModel.swift b/wled/View/DeviceAdd/DeviceAddViewModel.swift index 7a84c61..d16eca7 100644 --- a/wled/View/DeviceAdd/DeviceAddViewModel.swift +++ b/wled/View/DeviceAdd/DeviceAddViewModel.swift @@ -9,34 +9,25 @@ import Foundation @MainActor final class DeviceAddViewModel: ObservableObject { - + @Published var address: String = "" + @Published var useSecure: Bool = false + @Published var customName: String = "" @Published var currentStep: Step = .form() private let firstContactService = DeviceFirstContactService() - + + /// Returns the normalized full address including scheme using the current toggle selection. + var normalizedAddress: String? { + DeviceAddressNormalizer.normalizedAddress( + from: address, + defaultScheme: useSecure ? "https" : "http" + ) + } + var isAddressValid: Bool { - let cleanedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleanedAddress.isEmpty else { return false } - - let addressWithScheme: String - if cleanedAddress.lowercased().hasPrefix("http://") || cleanedAddress.lowercased().hasPrefix("https://") { - addressWithScheme = cleanedAddress - } else { - addressWithScheme = "http://\(cleanedAddress)" - } - - guard let components = URLComponents(string: addressWithScheme) else { - return false - } - - // This prevents valid URLs that are empty or just schemes (like "http://") - guard let host = components.host, !host.isEmpty else { - return false - } - - return true + normalizedAddress != nil } - + func submitCreateDevice() { if !isAddressValid { currentStep = .form(errorMessage: Error.enterValidAddress) @@ -46,16 +37,26 @@ final class DeviceAddViewModel: ObservableObject { await findDevice() } } - + /// Starts searching for the device and adds it, if one is found private func findDevice() async { currentStep = .adding do { + guard let normalizedAddress else { + currentStep = .form(errorMessage: Error.enterValidAddress) + return + } + let newDeviceId = try await firstContactService.fetchAndUpsertDevice( - rawAddress: address + rawAddress: normalizedAddress ) let viewContext = PersistenceController.shared.container.viewContext if let newDevice = viewContext.object(with: newDeviceId) as? Device { + let trimmedCustomName = customName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedCustomName.isEmpty { + newDevice.customName = trimmedCustomName + try viewContext.save() + } currentStep = .success(device: newDevice) } } catch let error { @@ -63,19 +64,19 @@ final class DeviceAddViewModel: ObservableObject { currentStep = .form(errorMessage: Error.cantConnect) } } - + // MARK: - State enum enum Step: Equatable { case form(errorMessage: String = "") case adding case success(device: Device) - + var isForm: Bool { if case .form = self { return true } return false } } - + // MARK: - Struct with magic stuff struct Error { static let enterValidAddress = String(localized: "Please enter a valid address") diff --git a/wled/View/DeviceInfoTwoRows.swift b/wled/View/DeviceInfoTwoRows.swift index 62bfcf2..770f18d 100644 --- a/wled/View/DeviceInfoTwoRows.swift +++ b/wled/View/DeviceInfoTwoRows.swift @@ -25,10 +25,11 @@ struct DeviceInfoTwoRows: View { } HStack(spacing: 4) { WebsocketStatusIndicator(currentStatus: device.websocketStatus) - Text(device.device.address ?? "") + Text(device.device.url?.absoluteString ?? "") .lineLimit(1) - .fixedSize() + .truncationMode(.middle) .lineSpacing(0) + .frame(maxWidth: .infinity, alignment: .leading) let signalStrength = Int(device.stateInfo?.info.wifi.signal ?? 0) Label { Text( @@ -61,6 +62,7 @@ struct DeviceInfoTwoRows: View { .accessibilityElement(children: .combine) .accessibilityLabel("(Hidden)") } + Spacer(minLength: 0) } .font(.subheadline.leading(.tight)) diff --git a/wled/View/DeviceView.swift b/wled/View/DeviceView.swift index df97c6b..7d90382 100644 --- a/wled/View/DeviceView.swift +++ b/wled/View/DeviceView.swift @@ -11,7 +11,7 @@ struct DeviceView: View { var body: some View { ZStack { - WebView(url: getDeviceAddress(), reload: $shouldWebViewRefresh) { _ in + WebView(url: device.device.url, reload: $shouldWebViewRefresh) { _ in withAnimation { showDownloadFinished = true } @@ -62,14 +62,6 @@ struct DeviceView: View { } } - func getDeviceAddress() -> URL? { - guard let deviceAddress = device.device.address, - let url = URL(string: "http://\(deviceAddress)") else { - return nil - } - return url - } - func getToolbarBadgeCount() -> Int { return device.hasUpdateAvailable ? 1 : 0 }