From 7edd9764d966b2419ed473093738bb0c3ba15948 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 17 Jun 2025 23:17:22 -0700 Subject: [PATCH 1/9] Bump version --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6f72607e..244ee935 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1808,7 +1808,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1841,7 +1841,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1872,7 +1872,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1904,7 +1904,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 24e7974f6e4f272b8486e3af494fcd3afb554974 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 01:00:27 -0700 Subject: [PATCH 2/9] iCloud Private key backup initial commit --- Localizable.xcstrings | 23 ++++ Meshtastic.xcodeproj/project.pbxproj | 4 + Meshtastic/Helpers/KeychainHelper.swift | 66 ++++++++++ Meshtastic/Meshtastic.entitlements | 12 +- .../Settings/Config/SecurityConfig.swift | 122 +++++++++++++----- 5 files changed, 191 insertions(+), 36 deletions(-) create mode 100644 Meshtastic/Helpers/KeychainHelper.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 9a6c28c1..b0443c35 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -2251,6 +2251,7 @@ } }, "Admin & Direct Message Keys" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2283,6 +2284,9 @@ } } } + }, + "Admin Keys" : { + }, "Administration" : { "localizations" : { @@ -3777,6 +3781,12 @@ } } } + }, + "Backup" : { + + }, + "Backup your private key to your iCloud keychain." : { + }, "Bad" : { "localizations" : { @@ -9373,6 +9383,9 @@ } } } + }, + "Direct Message Key" : { + }, "Direct Messages" : { "localizations" : { @@ -13128,6 +13141,9 @@ } } } + }, + "Generated from your public key and sent out to other nodes on the mesh to allow them to compute a shared secret key." : { + }, "Get custom waterproof solar and detection sensor router nodes, aluminium desktop nodes and rugged handsets." : { "localizations" : { @@ -15510,6 +15526,9 @@ } } } + }, + "Key Backup" : { + }, "Key Mapping" : { "localizations" : { @@ -24541,6 +24560,9 @@ } } } + }, + "Restore" : { + }, "Resume" : { "localizations" : { @@ -27216,6 +27238,7 @@ } }, "Sent out to other nodes on the mesh to allow them to compute a shared secret key." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 244ee935..d4ea70bc 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ DD1BD0EB2C601795008C0C70 /* CLLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0EA2C601795008C0C70 /* CLLocation.swift */; }; DD1BD0EE2C603C91008C0C70 /* CustomFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0ED2C603C91008C0C70 /* CustomFormatters.swift */; }; DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */; }; + DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF492E0292220090CE24 /* KeychainHelper.swift */; }; DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; }; DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2160AE28C5552500C17253 /* MQTTConfig.swift */; }; DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; }; @@ -372,6 +373,7 @@ DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 42.xcdatamodel"; sourceTree = ""; }; DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityConfig.swift; sourceTree = ""; }; DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 53.xcdatamodel"; sourceTree = ""; }; + DD1BEF492E0292220090CE24 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = ""; }; DD2160AE28C5552500C17253 /* MQTTConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTConfig.swift; sourceTree = ""; }; DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = ""; }; @@ -1067,6 +1069,7 @@ children = ( DDD43FE12A78C86B0083A3E9 /* Mqtt */, DDAF8C5226EB1DF10058C060 /* BLEManager.swift */, + DD1BEF492E0292220090CE24 /* KeychainHelper.swift */, DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */, DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */, DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */, @@ -1576,6 +1579,7 @@ 233E99C72D84A70900CC3A77 /* SoilCompactWidgets.swift in Sources */, BCE2D3C92C7C377F008E6199 /* FactoryResetNodeIntent.swift in Sources */, DD93800B2BA3F968008BEC06 /* NodeMapContent.swift in Sources */, + DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */, DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */, DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */, DDDB444429F8A8DD00EE2349 /* Float.swift in Sources */, diff --git a/Meshtastic/Helpers/KeychainHelper.swift b/Meshtastic/Helpers/KeychainHelper.swift new file mode 100644 index 00000000..73242474 --- /dev/null +++ b/Meshtastic/Helpers/KeychainHelper.swift @@ -0,0 +1,66 @@ +// +// KeychainHelper.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 6/17/25. +// +import Foundation +import Security + +class KeychainHelper { + + static let standard = KeychainHelper() + + private init() {} + + func save(key: String, value: String, service: String = Bundle.main.bundleIdentifier!) -> OSStatus { + let data = value.data(using: .utf8)! + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrSynchronizable as String: kCFBooleanTrue!, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + + SecItemDelete(query as CFDictionary) // Delete existing item if any + + let status = SecItemAdd(query as CFDictionary, nil) + return status + } + + func read(key: String, service: String = Bundle.main.bundleIdentifier!) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: kCFBooleanTrue, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrSynchronizable as String: kCFBooleanTrue! + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + if status == errSecSuccess { + if let data = item as? Data { + return String(data: data, encoding: .utf8) + } + } + return nil + } + + func delete(key: String, service: String = Bundle.main.bundleIdentifier!) -> OSStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecAttrSynchronizable as String: kCFBooleanTrue! + ] + + let status = SecItemDelete(query as CFDictionary) + return status + } +} diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 0d2247ee..3689ef3e 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -2,13 +2,15 @@ - com.apple.developer.usernotifications.critical-alerts - com.apple.developer.associated-domains applinks:meshtastic.org/e/* applinks:meshtastic.org/v/* + com.apple.developer.carplay-communication + + com.apple.developer.usernotifications.critical-alerts + com.apple.developer.weatherkit com.apple.security.app-sandbox @@ -21,7 +23,9 @@ com.apple.security.personal-information.location - com.apple.developer.carplay-communication - + keychain-access-groups + + $(AppIdentifierPrefix)gvh.MeshtasticClient + diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index 5954f47d..dc2d62b8 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -51,7 +51,7 @@ struct SecurityConfig: View { ConfigHeader(title: "Security", config: \.securityConfig, node: node, onAppear: setSecurityValues) Text("Security Config Settings require a firmware version 2.5+") .font(.title3) - Section(header: Text("Admin & Direct Message Keys")) { + Section(header: Text("Direct Message Key")) { VStack(alignment: .leading) { Label("Public Key", systemImage: "key") Text(publicKey) @@ -66,7 +66,7 @@ struct SecurityConfig: View { RoundedRectangle(cornerRadius: 10.0) .stroke(isValidKeyPair ? Color.clear : Color.red, lineWidth: 2.0) ) - Text("Sent out to other nodes on the mesh to allow them to compute a shared secret key.") + Text("Generated from your public key and sent out to other nodes on the mesh to allow them to compute a shared secret key.") .foregroundStyle(.secondary) .font(idiom == .phone ? .caption : .callout) Divider() @@ -79,6 +79,63 @@ struct SecurityConfig: View { Text("Used to create a shared key with a remote device.") .foregroundStyle(.secondary) .font(idiom == .phone ? .caption : .callout) + if let currentNode = node { + Divider() + Label("Key Backup", systemImage: "icloud") + HStack(alignment: .firstTextBaseline) { + let keychainKey = "PrivateKeyNode\(currentNode.num)" + Button { + let status = KeychainHelper.standard.save(key: keychainKey, value: privateKey) + if status == errSecSuccess { + print("Value saved successfully!") + } else { + print("Error saving value: \(status)") + } + } + label: { + Image(systemName: "icloud.and.arrow.up") + Text("Backup") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.small) + Spacer() + Button { + if let value = KeychainHelper.standard.read(key: keychainKey) { + self.privateKey = value + self.privateKeyIsSecure = false + } else { + print("No value found in Keychain for key: \(keychainKey)") + } + } + label: { + Image(systemName: "key.icloud") + Text("Restore") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.small) + Spacer() + Button { + let status = KeychainHelper.standard.delete(key: keychainKey) + if status == errSecSuccess { + print("Value deleted successfully!") + } else { + print("Error deleting value: \(status)") + } + } + label: { + Image(systemName: "trash") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.small) + } + Text("Backup your private key to your iCloud keychain.") + .foregroundStyle(.secondary) + .font(idiom == .phone ? .caption : .callout) + Divider() + } HStack(alignment: .firstTextBaseline) { Label("Regenerate Private Key", systemImage: "arrow.clockwise.circle") Spacer() @@ -95,38 +152,39 @@ struct SecurityConfig: View { .buttonBorderShape(.capsule) .controlSize(.small) } - Divider() - Label("Primary Admin Key", systemImage: "key.viewfinder") - SecureInput("Primary Admin Key", text: $adminKey, isValid: $hasValidAdminKey) - .background( - RoundedRectangle(cornerRadius: 10.0) - .stroke(hasValidAdminKey ? Color.clear : Color.red, lineWidth: 2.0) - ) - Text("The primary public key authorized to send admin messages to this node.") - .foregroundStyle(.secondary) - .font(idiom == .phone ? .caption : .callout) - Divider() - Label("Secondary Admin Key", systemImage: "key.viewfinder") - SecureInput("Secondary Admin Key", text: $adminKey2, isValid: $hasValidAdminKey2) - .background( - RoundedRectangle(cornerRadius: 10.0) - .stroke(hasValidAdminKey2 ? Color.clear : Color.red, lineWidth: 2.0) - ) - Text("The secondary public key authorized to send admin messages to this node.") - .foregroundStyle(.secondary) - .font(idiom == .phone ? .caption : .callout) - Divider() - Label("Tertiary Admin Key", systemImage: "key.viewfinder") - SecureInput("Tertiary Admin Key", text: $adminKey3, isValid: $hasValidAdminKey3) - .background( - RoundedRectangle(cornerRadius: 10.0) - .stroke(hasValidAdminKey3 ? Color.clear : Color.red, lineWidth: 2.0) - ) - Text("The tertiary public key authorized to send admin messages to this node.") - .foregroundStyle(.secondary) - .font(idiom == .phone ? .caption : .callout) } } + Section(header: Text("Admin Keys")) { + Label("Primary Admin Key", systemImage: "key.viewfinder") + SecureInput("Primary Admin Key", text: $adminKey, isValid: $hasValidAdminKey) + .background( + RoundedRectangle(cornerRadius: 10.0) + .stroke(hasValidAdminKey ? Color.clear : Color.red, lineWidth: 2.0) + ) + Text("The primary public key authorized to send admin messages to this node.") + .foregroundStyle(.secondary) + .font(idiom == .phone ? .caption : .callout) + Divider() + Label("Secondary Admin Key", systemImage: "key.viewfinder") + SecureInput("Secondary Admin Key", text: $adminKey2, isValid: $hasValidAdminKey2) + .background( + RoundedRectangle(cornerRadius: 10.0) + .stroke(hasValidAdminKey2 ? Color.clear : Color.red, lineWidth: 2.0) + ) + Text("The secondary public key authorized to send admin messages to this node.") + .foregroundStyle(.secondary) + .font(idiom == .phone ? .caption : .callout) + Divider() + Label("Tertiary Admin Key", systemImage: "key.viewfinder") + SecureInput("Tertiary Admin Key", text: $adminKey3, isValid: $hasValidAdminKey3) + .background( + RoundedRectangle(cornerRadius: 10.0) + .stroke(hasValidAdminKey3 ? Color.clear : Color.red, lineWidth: 2.0) + ) + Text("The tertiary public key authorized to send admin messages to this node.") + .foregroundStyle(.secondary) + .font(idiom == .phone ? .caption : .callout) + } Section(header: Text("Logs")) { Toggle(isOn: $serialEnabled) { Label("Serial Console", systemImage: "terminal") From 2d8ede1c7d00f9b7095ad87496f44a4107a6ba12 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 08:44:13 -0700 Subject: [PATCH 3/9] Success and Error states for key backup --- Localizable.xcstrings | 3 ++ Meshtastic.xcodeproj/project.pbxproj | 4 ++ Meshtastic/Enums/KeyBackupStatus.swift | 47 +++++++++++++++++++ .../Settings/Config/SecurityConfig.swift | 23 ++++++--- 4 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 Meshtastic/Enums/KeyBackupStatus.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index b0443c35..2676148d 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -13083,6 +13083,9 @@ } } } + }, + "Generate a new private key to replace the one currently in use. Public key will automatically be regenerated as well." : { + }, "Generate QR Code" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index d4ea70bc..745c3602 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -98,6 +98,7 @@ DD1BD0EE2C603C91008C0C70 /* CustomFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0ED2C603C91008C0C70 /* CustomFormatters.swift */; }; DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */; }; DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF492E0292220090CE24 /* KeychainHelper.swift */; }; + DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */; }; DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; }; DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2160AE28C5552500C17253 /* MQTTConfig.swift */; }; DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; }; @@ -374,6 +375,7 @@ DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityConfig.swift; sourceTree = ""; }; DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 53.xcdatamodel"; sourceTree = ""; }; DD1BEF492E0292220090CE24 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBackupStatus.swift; sourceTree = ""; }; DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = ""; }; DD2160AE28C5552500C17253 /* MQTTConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTConfig.swift; sourceTree = ""; }; DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = ""; }; @@ -878,6 +880,7 @@ DD8ED9C6289CE4A100B3B0AB /* Enums */ = { isa = PBXGroup; children = ( + DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */, DDA951592BC6624100CEA535 /* TelemetryWeather.swift */, DDE0F7C4295F77B700B8AAB3 /* AppSettingsEnums.swift */, DDB6ABD828B0A4BA00384BA1 /* BluetoothModes.swift */, @@ -1446,6 +1449,7 @@ 233E99C32D849D7A00CC3A77 /* WeightCompactWidget.swift in Sources */, DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */, DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */, + DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */, DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */, DD6F65722C6AB8EC0053C113 /* SecureInput.swift in Sources */, DD13AA492AB73BF400BA0C98 /* PositionPopover.swift in Sources */, diff --git a/Meshtastic/Enums/KeyBackupStatus.swift b/Meshtastic/Enums/KeyBackupStatus.swift new file mode 100644 index 00000000..ff5b2438 --- /dev/null +++ b/Meshtastic/Enums/KeyBackupStatus.swift @@ -0,0 +1,47 @@ +// +// iCloudStats.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 6/18/25. +// + +enum KeyBackupStatus: String, CaseIterable, Equatable, Decodable { + case saved + case restored + case deleted + case saveFailed + case restoreFailed + case deleteFailed + var description: String { + switch self { + case .saved: + return "Private Key saved successfully to iCloud keychain.".localized + case .restored: + return "Private Key restored successfully from iCloud keychain.".localized + case .deleted: + return "Private Key deleted successfully from iCloud keychain.".localized + case .saveFailed: + return "Private Key failed to save to iCloud keychain.".localized + case .restoreFailed: + return "Private Key value not found in iCloud keychain.".localized + case .deleteFailed: + return "Private Key failed to delete from iCloud keychain.".localized + } + } + var success: Bool { + switch self { + case .saved: + return true + case .restored: + return true + case .deleted: + return true + case .saveFailed: + return false + case .restoreFailed: + return false + case .deleteFailed: + return false + } + } +} diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index dc2d62b8..52a72e93 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -35,6 +35,8 @@ struct SecurityConfig: View { @State var serialEnabled = false @State var debugLogApiEnabled = false @State var privateKeyIsSecure = true + @State var backupStatus: KeyBackupStatus? + @State var backupStatusError: OSStatus? private var isValidKeyPair: Bool { guard let privateKeyBytes = Data(base64Encoded: privateKey), @@ -87,9 +89,10 @@ struct SecurityConfig: View { Button { let status = KeychainHelper.standard.save(key: keychainKey, value: privateKey) if status == errSecSuccess { - print("Value saved successfully!") + backupStatus = KeyBackupStatus.saved } else { - print("Error saving value: \(status)") + backupStatus = KeyBackupStatus.saveFailed + backupStatusError = status } } label: { @@ -104,8 +107,9 @@ struct SecurityConfig: View { if let value = KeychainHelper.standard.read(key: keychainKey) { self.privateKey = value self.privateKeyIsSecure = false + backupStatus = KeyBackupStatus.restored } else { - print("No value found in Keychain for key: \(keychainKey)") + backupStatus = KeyBackupStatus.restoreFailed } } label: { @@ -119,9 +123,9 @@ struct SecurityConfig: View { Button { let status = KeychainHelper.standard.delete(key: keychainKey) if status == errSecSuccess { - print("Value deleted successfully!") + backupStatus = KeyBackupStatus.deleted } else { - print("Error deleting value: \(status)") + backupStatus = KeyBackupStatus.deleteFailed } } label: { @@ -131,11 +135,17 @@ struct SecurityConfig: View { .buttonBorderShape(.capsule) .controlSize(.small) } + if let status = backupStatus { + let state = status.success + Text("\(status.description)") + .font(.caption) + .foregroundColor(state ? .green : .red) + } Text("Backup your private key to your iCloud keychain.") .foregroundStyle(.secondary) .font(idiom == .phone ? .caption : .callout) - Divider() } + Divider() HStack(alignment: .firstTextBaseline) { Label("Regenerate Private Key", systemImage: "arrow.clockwise.circle") Spacer() @@ -152,6 +162,7 @@ struct SecurityConfig: View { .buttonBorderShape(.capsule) .controlSize(.small) } + Text("Generate a new private key to replace the one currently in use. Public key will automatically be regenerated as well.") } } Section(header: Text("Admin Keys")) { From e0678258be2d14bb7a8b14d6184501f486de8fc8 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 16:44:21 -0700 Subject: [PATCH 4/9] Adjust key generation, clean up userid's --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- Meshtastic/Extensions/CoreData/UserEntityExtension.swift | 6 ++---- Meshtastic/Helpers/MeshPackets.swift | 4 ++-- Meshtastic/Persistence/UpdateCoreData.swift | 4 ++-- Meshtastic/Views/Nodes/Helpers/NodeDetail.swift | 2 +- Meshtastic/Views/Settings/Config/SecurityConfig.swift | 9 ++++++++- Meshtastic/Views/Settings/ShareChannels.swift | 2 +- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6f72607e..244ee935 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1808,7 +1808,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1841,7 +1841,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1872,7 +1872,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1904,7 +1904,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.7; + MARKETING_VERSION = 2.6.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift index 92f097b3..14bc4948 100644 --- a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift @@ -118,10 +118,8 @@ public func createUser(num: Int64, context: NSManagedObjectContext) throws -> Us context.performAndWait { newUser = UserEntity(context: context) newUser.num = num - - let userId = String(format: "%016llX", num) - newUser.userId = "!\(userId)" - + let userId = num.toHex() + newUser.userId = userId let last4 = String(userId.suffix(4)) newUser.longName = "Meshtastic \(last4)" newUser.shortName = last4 diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 75a62864..1e97611b 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -296,7 +296,7 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje if nodeInfo.hasUser { let newUser = UserEntity(context: context) - newUser.userId = nodeInfo.user.id + newUser.userId = nodeInfo.num.toHex() newUser.num = Int64(nodeInfo.num) newUser.longName = nodeInfo.user.longName newUser.shortName = nodeInfo.user.shortName @@ -394,7 +394,7 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje fetchedNode[0].user?.pkiEncrypted = true fetchedNode[0].user?.publicKey = nodeInfo.user.publicKey } - fetchedNode[0].user?.userId = nodeInfo.user.id + fetchedNode[0].user?.userId = nodeInfo.num.toHex() fetchedNode[0].user?.num = Int64(nodeInfo.num) fetchedNode[0].user?.numString = String(nodeInfo.num) fetchedNode[0].user?.longName = nodeInfo.user.longName diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index c241831a..78b14ef7 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -182,7 +182,7 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) } else { let newUser = UserEntity(context: context) - newUser.userId = newUserMessage.id + newUser.userId = newNode.num.toHex() newUser.num = Int64(packet.from) newUser.longName = newUserMessage.longName newUser.shortName = newUserMessage.shortName @@ -306,7 +306,7 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) fetchedNode[0].telemetries? = NSOrderedSet(array: newTelemetries) } if nodeInfoMessage.hasUser { - fetchedNode[0].user?.userId = nodeInfoMessage.user.id + fetchedNode[0].user?.userId = nodeInfoMessage.num.toHex() fetchedNode[0].user?.num = Int64(nodeInfoMessage.num) fetchedNode[0].user?.longName = nodeInfoMessage.user.longName fetchedNode[0].user?.shortName = nodeInfoMessage.user.shortName diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 80d10839..d98941bb 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -122,7 +122,7 @@ struct NodeDetail: View { .textSelection(.enabled) } .accessibilityElement(children: .combine) - + if node.user?.keyMatch ?? false { if let publicKey = node.user?.publicKey { HStack { diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index 5954f47d..006b8fa0 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -305,7 +305,14 @@ struct SecurityConfig: View { } if status == errSecSuccess { - return randomBytes + // Generate a random "f" value and then adjust the value to make + // it valid as an "s" value for eval(). According to the specification + // we need to mask off the 3 right-most bits of f[0], mask off the + // left-most bit of f[31], and set the second to left-most bit of f[31]. + var f = randomBytes + f[0] &= 0xF8 + f[31] = (f[31] & 0x7F) | 0x40 + return f } else { // Handle error, perhaps by logging or throwing an exception Logger.mesh.debug("Error generating random bytes: \(status)") diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index d1c09deb..1fd10c81 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -313,7 +313,7 @@ struct ShareChannels: View { guard let settingsString = try? channelSet.serializedData().base64EncodedString() else { return } - channelsUrl = ("https://meshtastic.org/e/\(replaceChannels ? "" : "?add=true")#" + settingsString.base64ToBase64url()) + channelsUrl = ("https://meshtastic.org/e/#\(settingsString.base64ToBase64url())\(replaceChannels ? "" : "?add=true")") } } } From 097d91059331fb6a5dde9fe62df6657b3ef40008 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 17:00:25 -0700 Subject: [PATCH 5/9] Update Meshtastic/Views/Settings/ShareChannels.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Meshtastic/Views/Settings/ShareChannels.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index 1fd10c81..7e4068e2 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -313,7 +313,7 @@ struct ShareChannels: View { guard let settingsString = try? channelSet.serializedData().base64EncodedString() else { return } - channelsUrl = ("https://meshtastic.org/e/#\(settingsString.base64ToBase64url())\(replaceChannels ? "" : "?add=true")") + channelsUrl = ("https://meshtastic.org/e/\(replaceChannels ? "" : "?add=true")#\(settingsString.base64ToBase64url())") } } } From 17c8ce671d5a74a5a454c32553ac5c61528ea7b5 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 17:04:58 -0700 Subject: [PATCH 6/9] Update channel qr code url matching --- Meshtastic/MeshtasticApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 685ae640..7cd636ac 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -59,7 +59,7 @@ struct MeshtasticAppleApp: App { self.saveChannels = false if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/v/#") == true { handleContactUrl(url: self.incomingUrl!) - } else if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/#") == true { + } else if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/") == true { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false if (self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil { From ca6cf606bceddd4c542a8d688649b096ec86970f Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 17:17:34 -0700 Subject: [PATCH 7/9] Fix a second channel url fragment --- Meshtastic/MeshtasticApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 7cd636ac..9a0e9165 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -87,7 +87,7 @@ struct MeshtasticAppleApp: App { self.incomingUrl = url if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { handleContactUrl(url: url) - } else if url.absoluteString.lowercased().contains("meshtastic.org/e/#") { + } else if url.absoluteString.lowercased().contains("meshtastic.org/e/") { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false if self.incomingUrl?.absoluteString.lowercased().contains("?") != nil { From 63bc7a5805b28f01ff54ff27f639aae642db656e Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 17:51:09 -0700 Subject: [PATCH 8/9] Channels help --- Localizable.xcstrings | 12 +++ Meshtastic.xcodeproj/project.pbxproj | 4 + .../Views/Helpers/Help/ChannelsHelp.swift | 76 +++++++++++++++++++ Meshtastic/Views/Messages/ChannelList.swift | 26 ++++++- Meshtastic/Views/Settings/Channels.swift | 7 ++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 Meshtastic/Views/Helpers/Help/ChannelsHelp.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 2676148d..0a5a5162 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1683,6 +1683,12 @@ } } } + }, + "A channel index of 0 indicates the primary channel where all broadcast packets are sent from." : { + + }, + "A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : { + }, "A Meshtastic QR code contains the LoRa config and channel values needed for radios to communicate. You can share a complete channel configuration using the Replace Channels option, if you choose Add Channels your shared channels will be added to the channels on the receiving radio." : { "localizations" : { @@ -1741,6 +1747,9 @@ } } } + }, + "A red lock with a slash means the channel is not securely encrypted, it uses either no key at all or a 1 byte known key. Traffic on this channel is easily intercepted." : { + }, "A Trace Route was sent, no response has been received." : { "localizations" : { @@ -6162,6 +6171,9 @@ } } } + }, + "Channels Help" : { + }, "Chart" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 745c3602..d583d3df 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -99,6 +99,7 @@ DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */; }; DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF492E0292220090CE24 /* KeychainHelper.swift */; }; DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */; }; + DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */; }; DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; }; DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2160AE28C5552500C17253 /* MQTTConfig.swift */; }; DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; }; @@ -376,6 +377,7 @@ DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 53.xcdatamodel"; sourceTree = ""; }; DD1BEF492E0292220090CE24 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBackupStatus.swift; sourceTree = ""; }; + DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsHelp.swift; sourceTree = ""; }; DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = ""; }; DD2160AE28C5552500C17253 /* MQTTConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTConfig.swift; sourceTree = ""; }; DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = ""; }; @@ -850,6 +852,7 @@ DD6F65772C6EAB860053C113 /* Help */ = { isa = PBXGroup; children = ( + DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */, DD6F65752C6EA5490053C113 /* AckErrors.swift */, DD6F65782C6EADE60053C113 /* DirectMessagesHelp.swift */, DD6F657A2C6EC2900053C113 /* LockLegend.swift */, @@ -1428,6 +1431,7 @@ D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */, DDC94FCE29CF55310082EA6E /* RtttlConfig.swift in Sources */, DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, + DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, diff --git a/Meshtastic/Views/Helpers/Help/ChannelsHelp.swift b/Meshtastic/Views/Helpers/Help/ChannelsHelp.swift new file mode 100644 index 00000000..830fe3cd --- /dev/null +++ b/Meshtastic/Views/Helpers/Help/ChannelsHelp.swift @@ -0,0 +1,76 @@ +// +// ChannelHelp.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 6/18/25. +// + +import SwiftUI + +struct ChannelsHelp: View { + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + Label("Channels Help", systemImage: "questionmark.circle") + .font(.title) + .padding(.vertical) + VStack(alignment: .leading) { + HStack { + CircleText(text: String(0), color: .accentColor) + .brightness(0.2) + .offset(y: -10) + Text("A channel index of 0 indicates the primary channel where all broadcast packets are sent from.") + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom) + } + HStack { + Image(systemName: "lock.fill") + .padding(.bottom) + .foregroundColor(Color.green) + .font(.largeTitle) + Text("A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key.") + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom) + } + HStack { + Image(systemName: "lock.slash.fill") + .padding(.bottom) + .foregroundColor(Color.red) + .font(.largeTitle) + Text("A red lock with a slash means the channel is not securely encrypted, it uses either no key at all or a 1 byte known key. Traffic on this channel is easily intercepted.") + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom) + } + } + +#if targetEnvironment(macCatalyst) + Spacer() + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) +#endif + } + .frame(minHeight: 0, maxHeight: .infinity, alignment: .leading) + .padding() + .presentationDetents([.large]) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .large)) + } +} + +struct ChannelHelpPreviews: PreviewProvider { + static var previews: some View { + VStack { + ChannelsHelp() + } + } +} diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index fec3ab8b..e97dbb47 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -21,8 +21,8 @@ struct ChannelList: View { var channelSelection: ChannelEntity? @State private var isPresentingDeleteChannelMessagesConfirm: Bool = false - @State private var isPresentingTraceRouteSentAlert = false + @State private var showingHelp = false var restrictedChannels = ["gpio", "mqtt", "serial", "admin"] @@ -168,8 +168,30 @@ struct ChannelList: View { } .padding([.top, .bottom]) .listStyle(.plain) + .navigationTitle("Channels") } } - .navigationTitle("Channels") + .sheet(isPresented: $showingHelp) { + ChannelsHelp() + .presentationDetents([.medium]) + } + .safeAreaInset(edge: .bottom, alignment: .leading) { + HStack { + Button(action: { + withAnimation { + showingHelp = !showingHelp + } + }) { + Image(systemName: !showingHelp ? "questionmark.circle" : "questionmark.circle.fill") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + .controlSize(.regular) + .padding(5) + } + .padding(.bottom, 5) } } diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index 0a24c8c5..d479403f 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -124,6 +124,13 @@ struct Channels: View { .brightness(0.1) VStack { HStack { + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash.fill") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } if channel.name?.isEmpty ?? false { if channel.role == 1 { Text(String("PrimaryChannel").camelCaseToWords()).font(.headline) From da6447aae5a94c765a3b858ad85fde6916d18c2b Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Jun 2025 17:55:41 -0700 Subject: [PATCH 9/9] detents --- Meshtastic/Views/Messages/ChannelList.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index e97dbb47..940396b9 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -173,7 +173,8 @@ struct ChannelList: View { } .sheet(isPresented: $showingHelp) { ChannelsHelp() - .presentationDetents([.medium]) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } .safeAreaInset(edge: .bottom, alignment: .leading) { HStack {