From 9d29682e2cdefc0963a11a0f99a170c68eaa421e Mon Sep 17 00:00:00 2001 From: smitty078 Date: Thu, 14 May 2026 16:10:43 -0400 Subject: [PATCH 1/8] reduced changes to just those necessary for URL handling --- wled/Service/DeviceApi/DeviceExtensions.swift | 24 ++- wled/Service/DeviceFirstContactService.swift | 67 +++--- wled/Service/DeviceUpdateService.swift | 6 +- wled/Service/Websocket/WebsocketClient.swift | 23 +-- wled/View/DeviceAdd/DeviceAddView.swift | 191 ++++++++++++++---- wled/View/DeviceAdd/DeviceAddViewModel.swift | 56 +++-- wled/View/DeviceView.swift | 10 +- 7 files changed, 268 insertions(+), 109 deletions(-) diff --git a/wled/Service/DeviceApi/DeviceExtensions.swift b/wled/Service/DeviceApi/DeviceExtensions.swift index bc6e77e..512b409 100644 --- a/wled/Service/DeviceApi/DeviceExtensions.swift +++ b/wled/Service/DeviceApi/DeviceExtensions.swift @@ -10,7 +10,29 @@ 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, diff --git a/wled/Service/DeviceFirstContactService.swift b/wled/Service/DeviceFirstContactService.swift index 7646779..34a5aee 100644 --- a/wled/Service/DeviceFirstContactService.swift +++ b/wled/Service/DeviceFirstContactService.swift @@ -51,7 +51,9 @@ 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 = normalizedAddress(from: rawAddress) else { + throw ServiceError.invalidURL + } logger.debug("Initiating contact with: \(cleanAddress)") let info = try await fetchDeviceInfo(address: cleanAddress) @@ -61,7 +63,7 @@ actor DeviceFirstContactService { 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. @@ -75,7 +77,7 @@ actor DeviceFirstContactService { 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 = normalizedAddress(from: address) else { return false } let logger = self.logger return await persistenceController.container.performBackgroundTask { context in @@ -87,7 +89,7 @@ actor DeviceFirstContactService { return false } - if existingDevice.address != address { + if existingDevice.address != cleanAddress { logger.info("Fast update: IP changed for \(existingDevice.originalName ?? "Unknown") (\(macAddress))") existingDevice.address = cleanAddress @@ -103,31 +105,50 @@ actor DeviceFirstContactService { // 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...]) + /// Normalizes the provided address into a canonical base URL string. + /// - Preserves https and http schemes. + /// - Defaults to http when no scheme is provided. + /// - Strips user info, path, query, and fragment components. + private func normalizedAddress(from address: String) -> 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 = "http://\(trimmed)" } - // Remove trailing slashes - while result.hasSuffix("/") { - result.removeLast() + guard var components = URLComponents(string: rawAddress), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host?.isEmpty == false else { + return nil } - return result + 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: "/")) } /// 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 @@ -142,7 +163,7 @@ actor DeviceFirstContactService { } /// 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 @@ -155,12 +176,12 @@ actor DeviceFirstContactService { 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,7 +189,7 @@ 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 } 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..ee78f3d 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,139 @@ 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("Optional", text: $viewModel.customName) + .multilineTextAlignment(.trailing) + .textInputAutocapitalization(.words) + .autocorrectionDisabled(true) + .focused($focusedField, equals: .customName) + .submitLabel(.next) + .onSubmit { + focusedField = .address + } + } + } + + Section { + LabeledContent("Address") { + TextField("http://host[:port][path]", text: $viewModel.address, axis: .vertical) + .lineLimit(1...3) + .multilineTextAlignment(.trailing) + .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 lowercased.hasPrefix("https://") { + viewModel.address.removeFirst("https://".count) + viewModel.address = "http://" + viewModel.address + } else if lowercased.hasPrefix("http://") { + viewModel.address.removeFirst("http://".count) + viewModel.address = "https://" + viewModel.address + } + } + )) + } footer: { + Text(verbatim: "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 +186,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("Adding \(address)") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -111,11 +204,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("\(device.displayName) was added") + .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..e1546cb 100644 --- a/wled/View/DeviceAdd/DeviceAddViewModel.swift +++ b/wled/View/DeviceAdd/DeviceAddViewModel.swift @@ -11,30 +11,46 @@ import Foundation 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() - var isAddressValid: Bool { - let cleanedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleanedAddress.isEmpty else { return false } + /// Returns the normalized full address including scheme using the current toggle selection. + var normalizedAddress: String? { + let cleaned = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + + let lowercasedAddress = cleaned.lowercased() + let rawAddress: String - let addressWithScheme: String - if cleanedAddress.lowercased().hasPrefix("http://") || cleanedAddress.lowercased().hasPrefix("https://") { - addressWithScheme = cleanedAddress + if lowercasedAddress.hasPrefix("http://") || lowercasedAddress.hasPrefix("https://") { + rawAddress = cleaned + } else if cleaned.contains("://") { + return nil } else { - addressWithScheme = "http://\(cleanedAddress)" + rawAddress = (useSecure ? "https://" : "http://") + cleaned } - guard let components = URLComponents(string: addressWithScheme) else { - return false + guard var components = URLComponents(string: rawAddress), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host?.isEmpty == false else { + return nil } - // This prevents valid URLs that are empty or just schemes (like "http://") - guard let host = components.host, !host.isEmpty else { - return false - } + components.user = nil + components.password = nil + components.path = "" + components.query = nil + components.fragment = nil + components.scheme = scheme - return true + return components.url?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + var isAddressValid: Bool { + normalizedAddress != nil } func submitCreateDevice() { @@ -51,11 +67,21 @@ final class DeviceAddViewModel: ObservableObject { 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 { 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 } From 4720ba9ea74df1110cce0751b301e00eff455d20 Mon Sep 17 00:00:00 2001 From: smitty078 Date: Thu, 14 May 2026 16:47:19 -0400 Subject: [PATCH 2/8] Update DeviceInfoTwoRows to now overflow on long URLs --- wled/View/DeviceInfoTwoRows.swift | 46 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/wled/View/DeviceInfoTwoRows.swift b/wled/View/DeviceInfoTwoRows.swift index 62bfcf2..6c1e011 100644 --- a/wled/View/DeviceInfoTwoRows.swift +++ b/wled/View/DeviceInfoTwoRows.swift @@ -10,7 +10,7 @@ import SwiftUI struct DeviceInfoTwoRows: View { @Environment(\.managedObjectContext) private var viewContext @ObservedObject var device: DeviceWithState - + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { @@ -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,13 +62,14 @@ struct DeviceInfoTwoRows: View { .accessibilityElement(children: .combine) .accessibilityLabel("(Hidden)") } + Spacer(minLength: 0) } .font(.subheadline.leading(.tight)) - + } .frame(maxWidth: .infinity, alignment: .leading) } - + func getUpdateIconName() -> String { if #available(iOS 17.0, *) { return "arrow.down.circle.dotted" @@ -75,11 +77,11 @@ struct DeviceInfoTwoRows: View { return "arrow.down.circle" } } - + @ViewBuilder func getSignalIcon(isOnline: Bool, signalStrength: Int?) -> some View { let icon = !isOnline || signalStrength == nil || signalStrength == 0 ? "wifi.slash" : "wifi" - + if #available(iOS 17.0, *) { Image(systemName: icon, variableValue: getSignalValue(signalStrength: signalStrength)) .symbolRenderingMode(.hierarchical) @@ -93,7 +95,7 @@ struct DeviceInfoTwoRows: View { .font(.caption2) } } - + func getSignalValue(signalStrength: Int?) -> Double { if let signalStrength { if signalStrength >= -67 { @@ -115,41 +117,41 @@ struct DeviceInfoTwoRows: View { struct OfflineSinceText: View { @ObservedObject var device: DeviceWithState @Environment(\.locale) private var locale - + private let formatter: RelativeDateTimeFormatter = { let fmt = RelativeDateTimeFormatter() fmt.unitsStyle = .full // Generates "10 minutes ago", "1 hour ago" fmt.dateTimeStyle = .named // Allows "yesterday" instead of "1 day ago" if appropriate return fmt }() - + var body: some View { // Update the view every minute to keep the "ago" text fresh TimelineView(.periodic(from: .now, by: 60)) { context in getOfflineText(now: context.date) } } - + private func getOfflineText(now: Date) -> Text { // lastSeen is Int64 milliseconds. 0 usually means never seen/unknown. let lastSeenMs = device.device.lastSeen - + guard lastSeenMs > 0 else { return Text("(Offline)") } - + formatter.locale = locale let lastSeenDate = Date(timeIntervalSince1970: TimeInterval(lastSeenMs) / 1000) let diff = now.timeIntervalSince(lastSeenDate) - + // Handle the "less than a minute" case manually if diff < 60 { return Text("(Offline, less than a minute ago)") } - + // For everything else (minutes, hours, days), let Apple handle the linguistics let timeString = formatter.localizedString(for: lastSeenDate, relativeTo: now) - + // formatter returns "10 minutes ago", so we prepend "Offline, " // Using string interpolation here works because the formatter output is already localized/pluralized return Text("(Offline, \(timeString))") @@ -161,14 +163,14 @@ struct OfflineSinceText: View { // MARK: DeviceInfoTwoRows preview struct DeviceInfoTwoRows_Previews: PreviewProvider { - + // Let's display a device with only one bar of signal static var hiddenDevice: DeviceWithState = { let device = PreviewData.hiddenDevice device.stateInfo?.info.wifi.signal = -86 return device }() - + static var previews: some View { VStack(spacing: 20) { DeviceInfoTwoRows(device: PreviewData.onlineDevice) @@ -189,7 +191,7 @@ struct OfflineSinceText_Previews: PreviewProvider { // English (Default) previewList .previewDisplayName("Offline Since (English)") - + // French (Explicit) previewList .environment(\.locale, Locale(identifier: "fr-CA")) @@ -197,7 +199,7 @@ struct OfflineSinceText_Previews: PreviewProvider { } .previewLayout(.sizeThatFits) } - + static var previewList: some View { VStack(alignment: .leading, spacing: 20) { createPreview(offset: -30, label: "Less than a minute") @@ -207,7 +209,7 @@ struct OfflineSinceText_Previews: PreviewProvider { } .padding() } - + // Helper to create the device and view static func createPreview(offset: TimeInterval, label: String) -> some View { let context = PersistenceController.preview.container.viewContext @@ -215,7 +217,7 @@ struct OfflineSinceText_Previews: PreviewProvider { // Convert Date to Int64 milliseconds device.lastSeen = Int64(Date().addingTimeInterval(offset).timeIntervalSince1970 * 1000) let deviceWithState = DeviceWithState(initialDevice: device) - + return VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption) From 8f31a9be00e644fb48e4b3983d0b1458f4bfda96 Mon Sep 17 00:00:00 2001 From: smitty078 Date: Thu, 14 May 2026 21:17:05 -0400 Subject: [PATCH 3/8] Fine tuning UI changes --- wled/Localizable.xcstrings | 174 ++++++++++++++---------- wled/View/DeviceAdd/DeviceAddView.swift | 91 ++++++------- 2 files changed, 146 insertions(+), 119 deletions(-) diff --git a/wled/Localizable.xcstrings b/wled/Localizable.xcstrings index cd1c150..2fb4aec 100644 --- a/wled/Localizable.xcstrings +++ b/wled/Localizable.xcstrings @@ -324,6 +324,17 @@ } } }, + "Controller Address or URL" : { + "comment" : "The label display in AddDeviceView for a user to enter controller address", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controller Address or URL" + } + } + } + }, "Controls" : { "extractionState" : "manual", "localizations" : { @@ -410,6 +421,9 @@ } } } + }, + "Device Added" : { + }, "Device List" : { "comment" : "The title of the device list screen.", @@ -626,6 +640,17 @@ } } }, + "Hostname, IP, or URL" : { + "comment" : "placeholder text for controller IP entry", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hostname, IP, or URL" + } + } + } + }, "Install" : { "comment" : "A button that installs a new software version on a device.", "isCommentAutoGenerated" : true, @@ -680,20 +705,53 @@ } } }, - "IP Address or URL" : { - "comment" : "A label above the text field where the user can enter the IP address or URL of a device", - "isCommentAutoGenerated" : true, + "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" : "IP Address or URL" + "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" : "Adresse IP ou URL" + "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." } } } @@ -857,6 +915,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 +1497,16 @@ } } }, + "Use Secure Connections" : { + "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 +1579,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" : "Explanation text on controller address entry", "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/View/DeviceAdd/DeviceAddView.swift b/wled/View/DeviceAdd/DeviceAddView.swift index ee78f3d..92f81e5 100644 --- a/wled/View/DeviceAdd/DeviceAddView.swift +++ b/wled/View/DeviceAdd/DeviceAddView.swift @@ -3,7 +3,7 @@ import SwiftUI struct DeviceAddView: View { @Environment(\.dismiss) var dismiss @StateObject private var viewModel = DeviceAddViewModel() - + var body: some View { NavigationStack { Group { @@ -50,7 +50,7 @@ struct DeviceAddView: View { } } } - + private var currentStepAnimationID: Int { switch viewModel.currentStep { case .form: @@ -68,14 +68,14 @@ struct DeviceAddView: View { struct DeviceAddStep1FormView: View { @ObservedObject var viewModel: DeviceAddViewModel @FocusState private var focusedField: Field? - + let errorMessage: String - + var body: some View { Form { Section { LabeledContent("Custom Name") { - TextField("Optional", text: $viewModel.customName) + TextField("Custom Name", text: $viewModel.customName) .multilineTextAlignment(.trailing) .textInputAutocapitalization(.words) .autocorrectionDisabled(true) @@ -86,47 +86,44 @@ struct DeviceAddStep1FormView: View { } } } - + Section { - LabeledContent("Address") { - TextField("http://host[:port][path]", text: $viewModel.address, axis: .vertical) - .lineLimit(1...3) - .multilineTextAlignment(.trailing) - .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 - } + TextField("Hostname, IP, 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 } - } - .focused($focusedField, equals: .address) - .submitLabel(.done) - .onSubmit { - normalizeAddressScheme() - withAnimation { - viewModel.submitCreateDevice() + } 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 lowercased.hasPrefix("https://") { viewModel.address.removeFirst("https://".count) viewModel.address = "http://" + viewModel.address @@ -136,10 +133,12 @@ struct DeviceAddStep1FormView: View { } } )) + } header: { + Text("Controller Address or URL") } footer: { - Text(verbatim: "WLED only supports HTTPS when accessed through a secure reverse proxy.") + Text("WLED only supports HTTPS when accessed through a secure reverse proxy.") } - + if !errorMessage.isEmpty { Section { Text(errorMessage) @@ -157,23 +156,23 @@ struct DeviceAddStep1FormView: View { } } } - + 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 @@ -184,12 +183,12 @@ struct DeviceAddStep1FormView: View { struct DeviceAddStep2LoadingView: View { let address: String - + var body: some View { VStack(spacing: 16) { ProgressView() .controlSize(.large) - + Text("Adding \(address)") .font(.headline) .foregroundStyle(.secondary) @@ -202,16 +201,16 @@ struct DeviceAddStep2LoadingView: View { struct DeviceAddStep3Success: View { let device: Device - + var body: some View { VStack(spacing: 16) { Image(systemName: "checkmark.seal") .font(.system(size: 48)) .foregroundStyle(.green) - + Text("Device Added") .font(.title3.bold()) - + Text("\(device.displayName) was added") .font(.headline) .foregroundStyle(.secondary) From 5d3914c5b756b82191479bc8df5205d20d8d5cbb Mon Sep 17 00:00:00 2001 From: smitty078 Date: Thu, 14 May 2026 21:30:51 -0400 Subject: [PATCH 4/8] Whitespace & Localization cleanup --- wled/Localizable.xcstrings | 43 ++++++++++++------------- wled/View/DeviceAdd/DeviceAddView.swift | 10 +++--- wled/View/DeviceInfoTwoRows.swift | 40 +++++++++++------------ 3 files changed, 45 insertions(+), 48 deletions(-) diff --git a/wled/Localizable.xcstrings b/wled/Localizable.xcstrings index 2fb4aec..b655246 100644 --- a/wled/Localizable.xcstrings +++ b/wled/Localizable.xcstrings @@ -324,17 +324,6 @@ } } }, - "Controller Address or URL" : { - "comment" : "The label display in AddDeviceView for a user to enter controller address", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Controller Address or URL" - } - } - } - }, "Controls" : { "extractionState" : "manual", "localizations" : { @@ -640,17 +629,6 @@ } } }, - "Hostname, IP, or URL" : { - "comment" : "placeholder text for controller IP entry", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hostname, IP, or URL" - } - } - } - }, "Install" : { "comment" : "A button that installs a new software version on a device.", "isCommentAutoGenerated" : true, @@ -705,6 +683,24 @@ } } }, + "IP Address or URL" : { + "comment" : "A label above the text field where the user can enter the IP address or URL of a device", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "IP Address or URL" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adresse IP ou URL" + } + } + } + }, "Local Network Access Required" : { "comment" : "Title of the warning banner that appears when the local network permission has been denied.", "localizations" : { @@ -1498,6 +1494,7 @@ } }, "Use Secure Connections" : { + "comment" : "Toggle switch text for when https/wss should be used", "localizations" : { "en" : { "stringUnit" : { @@ -1580,7 +1577,7 @@ } }, "WLED only supports HTTPS when accessed through a secure reverse proxy." : { - "comment" : "Explanation text on controller address entry", + "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" : { diff --git a/wled/View/DeviceAdd/DeviceAddView.swift b/wled/View/DeviceAdd/DeviceAddView.swift index 92f81e5..e31d207 100644 --- a/wled/View/DeviceAdd/DeviceAddView.swift +++ b/wled/View/DeviceAdd/DeviceAddView.swift @@ -68,7 +68,7 @@ struct DeviceAddView: View { struct DeviceAddStep1FormView: View { @ObservedObject var viewModel: DeviceAddViewModel @FocusState private var focusedField: Field? - + let errorMessage: String var body: some View { @@ -88,7 +88,7 @@ struct DeviceAddStep1FormView: View { } Section { - TextField("Hostname, IP, or URL", text: $viewModel.address, axis: .vertical) + TextField("IP Address or URL", text: $viewModel.address, axis: .vertical) .lineLimit(1) .keyboardType(.URL) .textInputAutocapitalization(.never) @@ -134,7 +134,7 @@ struct DeviceAddStep1FormView: View { } )) } header: { - Text("Controller Address or URL") + Text("IP Address or URL") } footer: { Text("WLED only supports HTTPS when accessed through a secure reverse proxy.") } @@ -183,7 +183,7 @@ struct DeviceAddStep1FormView: View { struct DeviceAddStep2LoadingView: View { let address: String - + var body: some View { VStack(spacing: 16) { ProgressView() @@ -201,7 +201,7 @@ struct DeviceAddStep2LoadingView: View { struct DeviceAddStep3Success: View { let device: Device - + var body: some View { VStack(spacing: 16) { Image(systemName: "checkmark.seal") diff --git a/wled/View/DeviceInfoTwoRows.swift b/wled/View/DeviceInfoTwoRows.swift index 6c1e011..770f18d 100644 --- a/wled/View/DeviceInfoTwoRows.swift +++ b/wled/View/DeviceInfoTwoRows.swift @@ -10,7 +10,7 @@ import SwiftUI struct DeviceInfoTwoRows: View { @Environment(\.managedObjectContext) private var viewContext @ObservedObject var device: DeviceWithState - + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { @@ -65,11 +65,11 @@ struct DeviceInfoTwoRows: View { Spacer(minLength: 0) } .font(.subheadline.leading(.tight)) - + } .frame(maxWidth: .infinity, alignment: .leading) } - + func getUpdateIconName() -> String { if #available(iOS 17.0, *) { return "arrow.down.circle.dotted" @@ -77,11 +77,11 @@ struct DeviceInfoTwoRows: View { return "arrow.down.circle" } } - + @ViewBuilder func getSignalIcon(isOnline: Bool, signalStrength: Int?) -> some View { let icon = !isOnline || signalStrength == nil || signalStrength == 0 ? "wifi.slash" : "wifi" - + if #available(iOS 17.0, *) { Image(systemName: icon, variableValue: getSignalValue(signalStrength: signalStrength)) .symbolRenderingMode(.hierarchical) @@ -95,7 +95,7 @@ struct DeviceInfoTwoRows: View { .font(.caption2) } } - + func getSignalValue(signalStrength: Int?) -> Double { if let signalStrength { if signalStrength >= -67 { @@ -117,41 +117,41 @@ struct DeviceInfoTwoRows: View { struct OfflineSinceText: View { @ObservedObject var device: DeviceWithState @Environment(\.locale) private var locale - + private let formatter: RelativeDateTimeFormatter = { let fmt = RelativeDateTimeFormatter() fmt.unitsStyle = .full // Generates "10 minutes ago", "1 hour ago" fmt.dateTimeStyle = .named // Allows "yesterday" instead of "1 day ago" if appropriate return fmt }() - + var body: some View { // Update the view every minute to keep the "ago" text fresh TimelineView(.periodic(from: .now, by: 60)) { context in getOfflineText(now: context.date) } } - + private func getOfflineText(now: Date) -> Text { // lastSeen is Int64 milliseconds. 0 usually means never seen/unknown. let lastSeenMs = device.device.lastSeen - + guard lastSeenMs > 0 else { return Text("(Offline)") } - + formatter.locale = locale let lastSeenDate = Date(timeIntervalSince1970: TimeInterval(lastSeenMs) / 1000) let diff = now.timeIntervalSince(lastSeenDate) - + // Handle the "less than a minute" case manually if diff < 60 { return Text("(Offline, less than a minute ago)") } - + // For everything else (minutes, hours, days), let Apple handle the linguistics let timeString = formatter.localizedString(for: lastSeenDate, relativeTo: now) - + // formatter returns "10 minutes ago", so we prepend "Offline, " // Using string interpolation here works because the formatter output is already localized/pluralized return Text("(Offline, \(timeString))") @@ -163,14 +163,14 @@ struct OfflineSinceText: View { // MARK: DeviceInfoTwoRows preview struct DeviceInfoTwoRows_Previews: PreviewProvider { - + // Let's display a device with only one bar of signal static var hiddenDevice: DeviceWithState = { let device = PreviewData.hiddenDevice device.stateInfo?.info.wifi.signal = -86 return device }() - + static var previews: some View { VStack(spacing: 20) { DeviceInfoTwoRows(device: PreviewData.onlineDevice) @@ -191,7 +191,7 @@ struct OfflineSinceText_Previews: PreviewProvider { // English (Default) previewList .previewDisplayName("Offline Since (English)") - + // French (Explicit) previewList .environment(\.locale, Locale(identifier: "fr-CA")) @@ -199,7 +199,7 @@ struct OfflineSinceText_Previews: PreviewProvider { } .previewLayout(.sizeThatFits) } - + static var previewList: some View { VStack(alignment: .leading, spacing: 20) { createPreview(offset: -30, label: "Less than a minute") @@ -209,7 +209,7 @@ struct OfflineSinceText_Previews: PreviewProvider { } .padding() } - + // Helper to create the device and view static func createPreview(offset: TimeInterval, label: String) -> some View { let context = PersistenceController.preview.container.viewContext @@ -217,7 +217,7 @@ struct OfflineSinceText_Previews: PreviewProvider { // Convert Date to Int64 milliseconds device.lastSeen = Int64(Date().addingTimeInterval(offset).timeIntervalSince1970 * 1000) let deviceWithState = DeviceWithState(initialDevice: device) - + return VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption) From 7907b7388cbdf3795424ea867eefc39da847dfc7 Mon Sep 17 00:00:00 2001 From: smitty078 Date: Thu, 14 May 2026 21:43:48 -0400 Subject: [PATCH 5/8] centralize adddress normalization. fix localization --- wled/Localizable.xcstrings | 9 +- wled/Service/DeviceApi/DeviceExtensions.swift | 46 ++++++++-- wled/Service/DeviceFirstContactService.swift | 90 ++++++------------- wled/View/DeviceAdd/DeviceAddViewModel.swift | 51 +++-------- 4 files changed, 88 insertions(+), 108 deletions(-) diff --git a/wled/Localizable.xcstrings b/wled/Localizable.xcstrings index b655246..1d90b83 100644 --- a/wled/Localizable.xcstrings +++ b/wled/Localizable.xcstrings @@ -412,7 +412,14 @@ } }, "Device Added" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device Added" + } + } + } }, "Device List" : { "comment" : "The title of the device list screen.", diff --git a/wled/Service/DeviceApi/DeviceExtensions.swift b/wled/Service/DeviceApi/DeviceExtensions.swift index 512b409..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 { @@ -10,7 +44,7 @@ extension Device { } return String(localized: "(New Device)") } - + var url: URL? { guard let address = address?.trimmingCharacters(in: .whitespacesAndNewlines), !address.isEmpty else { @@ -18,21 +52,21 @@ extension Device { } 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, @@ -41,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 34a5aee..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,21 +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 { - guard let cleanAddress = normalizedAddress(from: rawAddress) else { + 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, 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. /// @@ -75,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 - guard let cleanAddress = normalizedAddress(from: address) else { return false } + 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 != cleanAddress { logger.info("Fast update: IP changed for \(existingDevice.originalName ?? "Unknown") (\(macAddress))") existingDevice.address = cleanAddress - + do { try context.save() } catch { @@ -102,45 +102,9 @@ actor DeviceFirstContactService { return true } } - + // MARK: - Private Helpers - - /// Normalizes the provided address into a canonical base URL string. - /// - Preserves https and http schemes. - /// - Defaults to http when no scheme is provided. - /// - Strips user info, path, query, and fragment components. - private func normalizedAddress(from address: String) -> 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 = "http://\(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: "/")) - } - + /// Fetches device information from the specified address. private func fetchDeviceInfo(address: String) async throws -> Info { guard let base = URL(string: address) else { @@ -149,11 +113,11 @@ actor DeviceFirstContactService { 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) @@ -161,19 +125,19 @@ actor DeviceFirstContactService { throw ServiceError.networkError(error) } } - + /// Handles the Core Data logic to find, update, or create the device. 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 == address && existingDevice.originalName == name { @@ -193,11 +157,11 @@ actor DeviceFirstContactService { device.originalName = name device.isHidden = false } - + if context.hasChanges { try context.save() } - + return device.objectID } } diff --git a/wled/View/DeviceAdd/DeviceAddViewModel.swift b/wled/View/DeviceAdd/DeviceAddViewModel.swift index e1546cb..d16eca7 100644 --- a/wled/View/DeviceAdd/DeviceAddViewModel.swift +++ b/wled/View/DeviceAdd/DeviceAddViewModel.swift @@ -9,50 +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? { - let cleaned = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return nil } - - let lowercasedAddress = cleaned.lowercased() - let rawAddress: String - - if lowercasedAddress.hasPrefix("http://") || lowercasedAddress.hasPrefix("https://") { - rawAddress = cleaned - } else if cleaned.contains("://") { - return nil - } else { - rawAddress = (useSecure ? "https://" : "http://") + cleaned - } - - 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: "/")) + DeviceAddressNormalizer.normalizedAddress( + from: address, + defaultScheme: useSecure ? "https" : "http" + ) } - + var isAddressValid: Bool { normalizedAddress != nil } - + func submitCreateDevice() { if !isAddressValid { currentStep = .form(errorMessage: Error.enterValidAddress) @@ -62,7 +37,7 @@ final class DeviceAddViewModel: ObservableObject { await findDevice() } } - + /// Starts searching for the device and adds it, if one is found private func findDevice() async { currentStep = .adding @@ -71,7 +46,7 @@ final class DeviceAddViewModel: ObservableObject { currentStep = .form(errorMessage: Error.enterValidAddress) return } - + let newDeviceId = try await firstContactService.fetchAndUpsertDevice( rawAddress: normalizedAddress ) @@ -89,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") From df5a28277954b9cce9f1f3be7d9435f5078933c6 Mon Sep 17 00:00:00 2001 From: smitty078 <304245+smitty078@users.noreply.github.com> Date: Thu, 14 May 2026 21:55:37 -0400 Subject: [PATCH 6/8] Update wled/View/DeviceAdd/DeviceAddView.swift Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- wled/View/DeviceAdd/DeviceAddView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wled/View/DeviceAdd/DeviceAddView.swift b/wled/View/DeviceAdd/DeviceAddView.swift index e31d207..ef11316 100644 --- a/wled/View/DeviceAdd/DeviceAddView.swift +++ b/wled/View/DeviceAdd/DeviceAddView.swift @@ -124,12 +124,12 @@ struct DeviceAddStep1FormView: View { let lowercased = viewModel.address.lowercased() - if lowercased.hasPrefix("https://") { - viewModel.address.removeFirst("https://".count) - viewModel.address = "http://" + viewModel.address - } else if lowercased.hasPrefix("http://") { + 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 } } )) From dd6227b716a6d867cf3e8ec2ef8bfbdfea13a3c5 Mon Sep 17 00:00:00 2001 From: smitty078 <304245+smitty078@users.noreply.github.com> Date: Thu, 14 May 2026 21:55:53 -0400 Subject: [PATCH 7/8] Update wled/View/DeviceAdd/DeviceAddView.swift Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- wled/View/DeviceAdd/DeviceAddView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled/View/DeviceAdd/DeviceAddView.swift b/wled/View/DeviceAdd/DeviceAddView.swift index ef11316..20b4514 100644 --- a/wled/View/DeviceAdd/DeviceAddView.swift +++ b/wled/View/DeviceAdd/DeviceAddView.swift @@ -189,7 +189,7 @@ struct DeviceAddStep2LoadingView: View { ProgressView() .controlSize(.large) - Text("Adding \(address)") + Text(String(format: NSLocalizedString("Adding %@", comment: ""), address)) .font(.headline) .foregroundStyle(.secondary) } From 3ce4b221db8586540e33f6e17cb47188ad8be3f1 Mon Sep 17 00:00:00 2001 From: smitty078 <304245+smitty078@users.noreply.github.com> Date: Thu, 14 May 2026 21:56:04 -0400 Subject: [PATCH 8/8] Update wled/View/DeviceAdd/DeviceAddView.swift Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- wled/View/DeviceAdd/DeviceAddView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wled/View/DeviceAdd/DeviceAddView.swift b/wled/View/DeviceAdd/DeviceAddView.swift index 20b4514..c4bdac4 100644 --- a/wled/View/DeviceAdd/DeviceAddView.swift +++ b/wled/View/DeviceAdd/DeviceAddView.swift @@ -211,7 +211,7 @@ struct DeviceAddStep3Success: View { Text("Device Added") .font(.title3.bold()) - Text("\(device.displayName) was added") + Text(String(format: NSLocalizedString("%@ was added", comment: ""), device.displayName)) .font(.headline) .foregroundStyle(.secondary) .multilineTextAlignment(.center)