From 7b53e99c4a316d50eb33993f670babca6bf51409 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 18 Apr 2026 23:09:32 -0400 Subject: [PATCH 1/7] feat(settings): add DeveloperSettings with tap-gate logic + tests --- Clave.xcodeproj/project.pbxproj | 6 ++++ ClaveTests/DeveloperSettingsTests.swift | 33 ++++++++++++++++++++++ Shared/DeveloperSettings.swift | 37 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 ClaveTests/DeveloperSettingsTests.swift create mode 100644 Shared/DeveloperSettings.swift diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index 3e21d77..010ed37 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ EFBF443D3DA775A267700410 /* Bech32.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3D7A582F8BD020005A6545 /* Bech32.swift */; }; EFC7FD75109694C0A7F86D26 /* SharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFCA55B463A619311A35AF3C /* SharedModels.swift */; }; EFE12D1EC4CE48622767D427 /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */; }; + DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; + DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -91,6 +93,7 @@ EF3D7A5F2F8BD020005A6545 /* SignerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignerService.swift; sourceTree = ""; }; EFCA55B463A619311A35AF3C /* SharedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedModels.swift; sourceTree = ""; }; EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStorage.swift; sourceTree = ""; }; + DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -202,6 +205,7 @@ children = ( EF3D7A582F8BD020005A6545 /* Bech32.swift */, 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */, + DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */, 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */, EF3D7A592F8BD020005A6545 /* LightCrypto.swift */, EF3D7A5A2F8BD020005A6545 /* LightEvent.swift */, @@ -414,6 +418,7 @@ EF6058BFB67AD8970BBE8953 /* LightEvent.swift in Sources */, EF1963B89F3A6472380F1E0A /* LightRelay.swift in Sources */, EF85F076F6C02095C4B6D9C4 /* LightSigner.swift in Sources */, + DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -446,6 +451,7 @@ EFAF49B35F7A4241A65DCD89 /* SharedStorage.swift in Sources */, BD247C1AD3A7497E8DF29530 /* ClientPermissions.swift in Sources */, 006B17A84C114EDF9B129CAE /* NostrConnectParser.swift in Sources */, + DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ClaveTests/DeveloperSettingsTests.swift b/ClaveTests/DeveloperSettingsTests.swift new file mode 100644 index 0000000..c866146 --- /dev/null +++ b/ClaveTests/DeveloperSettingsTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Clave + +final class DeveloperSettingsTests: XCTestCase { + + func testTapGateUnlocksOnSeventhTap() { + let base = Date(timeIntervalSince1970: 1_000_000) + let timestamps = (0..<7).map { base.addingTimeInterval(TimeInterval($0) * 0.3) } + XCTAssertTrue(DeveloperSettings.tapGateSatisfied(timestamps: timestamps, window: 3.0, required: 7)) + } + + func testTapGateRejectsSixTaps() { + let base = Date(timeIntervalSince1970: 1_000_000) + let timestamps = (0..<6).map { base.addingTimeInterval(TimeInterval($0) * 0.3) } + XCTAssertFalse(DeveloperSettings.tapGateSatisfied(timestamps: timestamps, window: 3.0, required: 7)) + } + + func testTapGateRejectsSlowTaps() { + let base = Date(timeIntervalSince1970: 1_000_000) + // 7 taps but spread over 10 seconds + let timestamps = (0..<7).map { base.addingTimeInterval(TimeInterval($0) * 1.5) } + XCTAssertFalse(DeveloperSettings.tapGateSatisfied(timestamps: timestamps, window: 3.0, required: 7)) + } + + func testTapGateUsesOnlyMostRecentTaps() { + // Old taps followed by a fresh burst of 7 within window should unlock + let oldBase = Date(timeIntervalSince1970: 1_000_000) + let freshBase = Date(timeIntervalSince1970: 1_000_100) + let oldTaps = (0..<3).map { oldBase.addingTimeInterval(TimeInterval($0)) } + let freshTaps = (0..<7).map { freshBase.addingTimeInterval(TimeInterval($0) * 0.3) } + XCTAssertTrue(DeveloperSettings.tapGateSatisfied(timestamps: oldTaps + freshTaps, window: 3.0, required: 7)) + } +} diff --git a/Shared/DeveloperSettings.swift b/Shared/DeveloperSettings.swift new file mode 100644 index 0000000..7683dd3 --- /dev/null +++ b/Shared/DeveloperSettings.swift @@ -0,0 +1,37 @@ +import Foundation +import Observation + +@Observable +final class DeveloperSettings: @unchecked Sendable { + static let shared = DeveloperSettings() + + private let defaults: UserDefaults + + private enum Key { + static let developerMenuUnlocked = "dev.nostr.clave.developerMenuUnlocked" + static let nostrconnectEnabled = "dev.nostr.clave.nostrconnectEnabled" + } + + var developerMenuUnlocked: Bool { + didSet { defaults.set(developerMenuUnlocked, forKey: Key.developerMenuUnlocked) } + } + + var nostrconnectEnabled: Bool { + didSet { defaults.set(nostrconnectEnabled, forKey: Key.nostrconnectEnabled) } + } + + init(defaults: UserDefaults = SharedConstants.sharedDefaults) { + self.defaults = defaults + self.developerMenuUnlocked = defaults.bool(forKey: Key.developerMenuUnlocked) + self.nostrconnectEnabled = defaults.bool(forKey: Key.nostrconnectEnabled) + } + + /// Pure helper: returns true if the most recent `required` timestamps all fall within `window` seconds of each other. + /// Used by the tap-count-to-unlock gesture on the Version row. + nonisolated static func tapGateSatisfied(timestamps: [Date], window: TimeInterval, required: Int) -> Bool { + guard timestamps.count >= required else { return false } + let recent = timestamps.suffix(required) + guard let first = recent.first, let last = recent.last else { return false } + return last.timeIntervalSince(first) <= window + } +} From 1d5b9d4225e36ba0d7253b0cb2990682f82f1bdd Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 18 Apr 2026 23:23:48 -0400 Subject: [PATCH 2/7] feat(logs): add LogExporter wrapping OSLogStore + formatting tests --- Clave.xcodeproj/project.pbxproj | 12 ++++-- ClaveTests/LogExporterFormattingTests.swift | 47 ++++++++++++++++++++ Shared/LogExporter.swift | 48 +++++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 ClaveTests/LogExporterFormattingTests.swift create mode 100644 Shared/LogExporter.swift diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index 010ed37..ac51fcd 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ 6D2C503B9EF64C8EA5101CDB /* NostrConnectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */; }; BD247C1AD3A7497E8DF29530 /* ClientPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */; }; DD90DE0C2D8944D08B23C606 /* ClientPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */; }; + DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; + DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; + DE7E10C2DE7E10C2DE7E10C2 /* LogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */; }; + DE7E10C3DE7E10C3DE7E10C3 /* LogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */; }; EF1963B89F3A6472380F1E0A /* LightRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3D7A5B2F8BD020005A6545 /* LightRelay.swift */; }; EF251FEC744434349FD7256D /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = EF609A96DAD7E5693CA7EE4D /* P256K */; }; EF304CFAA8D958E7CCDF01AD /* LightCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3D7A592F8BD020005A6545 /* LightCrypto.swift */; }; @@ -34,8 +38,6 @@ EFBF443D3DA775A267700410 /* Bech32.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3D7A582F8BD020005A6545 /* Bech32.swift */; }; EFC7FD75109694C0A7F86D26 /* SharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFCA55B463A619311A35AF3C /* SharedModels.swift */; }; EFE12D1EC4CE48622767D427 /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */; }; - DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; - DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -79,6 +81,8 @@ /* Begin PBXFileReference section */ 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnectParser.swift; sourceTree = ""; }; 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientPermissions.swift; sourceTree = ""; }; + DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; + DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = ""; }; EF3D7A0F2F8BCAE3005A6545 /* Clave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Clave.app; sourceTree = BUILT_PRODUCTS_DIR; }; EF3D7A1C2F8BCAE6005A6545 /* ClaveTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaveTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EF3D7A262F8BCAE6005A6545 /* ClaveUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaveUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -93,7 +97,6 @@ EF3D7A5F2F8BD020005A6545 /* SignerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignerService.swift; sourceTree = ""; }; EFCA55B463A619311A35AF3C /* SharedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedModels.swift; sourceTree = ""; }; EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStorage.swift; sourceTree = ""; }; - DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -206,6 +209,7 @@ EF3D7A582F8BD020005A6545 /* Bech32.swift */, 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */, DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */, + DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */, 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */, EF3D7A592F8BD020005A6545 /* LightCrypto.swift */, EF3D7A5A2F8BD020005A6545 /* LightEvent.swift */, @@ -419,6 +423,7 @@ EF1963B89F3A6472380F1E0A /* LightRelay.swift in Sources */, EF85F076F6C02095C4B6D9C4 /* LightSigner.swift in Sources */, DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */, + DE7E10C2DE7E10C2DE7E10C2 /* LogExporter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -452,6 +457,7 @@ BD247C1AD3A7497E8DF29530 /* ClientPermissions.swift in Sources */, 006B17A84C114EDF9B129CAE /* NostrConnectParser.swift in Sources */, DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */, + DE7E10C3DE7E10C3DE7E10C3 /* LogExporter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ClaveTests/LogExporterFormattingTests.swift b/ClaveTests/LogExporterFormattingTests.swift new file mode 100644 index 0000000..9fe9681 --- /dev/null +++ b/ClaveTests/LogExporterFormattingTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import Clave + +final class LogExporterFormattingTests: XCTestCase { + + func testFormatEntriesProducesIsoTimestampCategoryMessage() { + let entries = [ + LogExporter.Entry( + date: Date(timeIntervalSince1970: 1_776_600_000), + category: "relay", + message: "Connected to wss://relay.powr.build" + ), + LogExporter.Entry( + date: Date(timeIntervalSince1970: 1_776_600_001), + category: "signer", + message: "Method: sign_event" + ) + ] + let output = LogExporter.format(entries: entries) + let lines = output.split(separator: "\n") + XCTAssertEqual(lines.count, 2) + XCTAssertTrue(lines[0].contains("[relay]")) + XCTAssertTrue(lines[0].contains("Connected to wss://relay.powr.build")) + XCTAssertTrue(lines[1].contains("[signer]")) + XCTAssertTrue(lines[1].contains("Method: sign_event")) + } + + func testFormatEntriesWithCategoryFilter() { + let entries = [ + LogExporter.Entry(date: Date(), category: "relay", message: "A"), + LogExporter.Entry(date: Date(), category: "signer", message: "B"), + LogExporter.Entry(date: Date(), category: "storage", message: "C") + ] + let output = LogExporter.format(entries: entries, categories: ["signer"]) + XCTAssertFalse(output.contains("A")) + XCTAssertTrue(output.contains("B")) + XCTAssertFalse(output.contains("C")) + } + + func testFormatEntriesEmptyWhenNoMatches() { + let entries = [ + LogExporter.Entry(date: Date(), category: "relay", message: "A") + ] + let output = LogExporter.format(entries: entries, categories: ["signer"]) + XCTAssertEqual(output, "") + } +} diff --git a/Shared/LogExporter.swift b/Shared/LogExporter.swift new file mode 100644 index 0000000..33ecafc --- /dev/null +++ b/Shared/LogExporter.swift @@ -0,0 +1,48 @@ +import Foundation +import OSLog + +enum LogExporter { + + struct Entry: Equatable { + let date: Date + let category: String + let message: String + } + + /// Known log categories used across Clave's main app. NSE's "signer" category also + /// writes under this subsystem but runs in a different process and is NOT captured + /// by `.currentProcessIdentifier` scope. + static let allCategories: [String] = ["relay", "signer", "storage", "apns", "app"] + + /// Fetch main-app logs from the unified log store within the given time window. + /// Returns an empty array on failure (no exception — this is a debug convenience). + static func fetchRecentLogs(since: Date) -> [Entry] { + guard let store = try? OSLogStore(scope: .currentProcessIdentifier) else { + return [] + } + let position = store.position(date: since) + guard let allEntries = try? store.getEntries(at: position) else { return [] } + return allEntries + .compactMap { $0 as? OSLogEntryLog } + .filter { $0.subsystem == "dev.nostr.clave" } + .map { Entry(date: $0.date, category: $0.category, message: $0.composedMessage) } + } + + /// Pure formatter — testable without OSLogStore. + static func format(entries: [Entry], categories: Set? = nil) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return entries + .filter { entry in + guard let categories else { return true } + return categories.contains(entry.category) + } + .map { "\(formatter.string(from: $0.date)) [\($0.category)] \($0.message)" } + .joined(separator: "\n") + } + + /// Convenience overload accepting Array for call-site ergonomics. + static func format(entries: [Entry], categories: [String]) -> String { + format(entries: entries, categories: Set(categories)) + } +} From fd4c81c9a776f7545af886c889b8dc39b2112f1d Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 18 Apr 2026 23:30:57 -0400 Subject: [PATCH 3/7] feat(settings): add Developer section with 7-tap version unlock + log copy --- Clave/Views/Settings/SettingsView.swift | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Clave/Views/Settings/SettingsView.swift b/Clave/Views/Settings/SettingsView.swift index 409797b..3378aed 100644 --- a/Clave/Views/Settings/SettingsView.swift +++ b/Clave/Views/Settings/SettingsView.swift @@ -7,6 +7,9 @@ struct SettingsView: View { @State private var showExportSheet = false @State private var proxyURL = "" @State private var registrationStatus = "" + @State private var devSettings = DeveloperSettings.shared + @State private var versionTapTimes: [Date] = [] + @State private var showCopyLogsConfirmation = false var body: some View { NavigationStack { @@ -16,6 +19,9 @@ struct SettingsView: View { pushProxySection relaySection aboutSection + if devSettings.developerMenuUnlocked { + developerSection + } } .navigationTitle("Settings") .onAppear { loadSettings() } @@ -160,6 +166,65 @@ struct SettingsView: View { Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") .foregroundStyle(.secondary) } + .contentShape(Rectangle()) + .onTapGesture { + versionTapTimes.append(Date()) + // Keep only the most recent 10 to bound memory + if versionTapTimes.count > 10 { + versionTapTimes = Array(versionTapTimes.suffix(10)) + } + if DeveloperSettings.tapGateSatisfied(timestamps: versionTapTimes, window: 3.0, required: 7) { + if !devSettings.developerMenuUnlocked { + devSettings.developerMenuUnlocked = true + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + versionTapTimes = [] + } + } + } + } + + // MARK: - Developer + + private var developerSection: some View { + Section("Developer") { + Toggle(isOn: $devSettings.nostrconnectEnabled) { + VStack(alignment: .leading) { + Text("Enable Nostrconnect") + Text("Experimental — some clients have compatibility issues. Use bunker:// for reliable signing.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Button { + let logs = LogExporter.format( + entries: LogExporter.fetchRecentLogs(since: Date().addingTimeInterval(-3600)) + ) + if logs.isEmpty { + UIPasteboard.general.string = "(no logs in the last hour)" + } else { + UIPasteboard.general.string = logs + } + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + showCopyLogsConfirmation = true + } label: { + Label("Copy Recent Logs (last hour)", systemImage: "doc.on.clipboard") + } + + Text("Captures Clave main-app logs only. NSE (signing) runs in a separate process and is not included — use Xcode Console for NSE logs.") + .font(.caption2) + .foregroundStyle(.secondary) + + Button(role: .destructive) { + devSettings.developerMenuUnlocked = false + versionTapTimes = [] + } label: { + Label("Lock Developer Menu", systemImage: "lock") + } + } + .alert("Logs copied to clipboard", isPresented: $showCopyLogsConfirmation) { + Button("OK", role: .cancel) {} } } From 2d562c5b97b17069085033e5b3acd33a835be48a Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 18 Apr 2026 23:36:23 -0400 Subject: [PATCH 4/7] fix(settings): move log fetch off main thread to avoid UI hang --- Clave/Views/Settings/SettingsView.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Clave/Views/Settings/SettingsView.swift b/Clave/Views/Settings/SettingsView.swift index 3378aed..cd3f676 100644 --- a/Clave/Views/Settings/SettingsView.swift +++ b/Clave/Views/Settings/SettingsView.swift @@ -198,16 +198,15 @@ struct SettingsView: View { } Button { - let logs = LogExporter.format( - entries: LogExporter.fetchRecentLogs(since: Date().addingTimeInterval(-3600)) - ) - if logs.isEmpty { - UIPasteboard.general.string = "(no logs in the last hour)" - } else { - UIPasteboard.general.string = logs + Task.detached(priority: .userInitiated) { + let entries = LogExporter.fetchRecentLogs(since: Date().addingTimeInterval(-3600)) + let logs = LogExporter.format(entries: entries) + await MainActor.run { + UIPasteboard.general.string = logs.isEmpty ? "(no logs in the last hour)" : logs + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + showCopyLogsConfirmation = true + } } - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - showCopyLogsConfirmation = true } label: { Label("Copy Recent Logs (last hour)", systemImage: "doc.on.clipboard") } From 188ce9f2c66a87b9da297786b112ffd00a5ac8c7 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 18 Apr 2026 23:38:58 -0400 Subject: [PATCH 5/7] feat(connect): gate nostrconnect UI behind DeveloperSettings flag --- Clave/Views/Home/ConnectSheet.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Clave/Views/Home/ConnectSheet.swift b/Clave/Views/Home/ConnectSheet.swift index ca3af80..eb31dcd 100644 --- a/Clave/Views/Home/ConnectSheet.swift +++ b/Clave/Views/Home/ConnectSheet.swift @@ -17,7 +17,9 @@ struct ConnectSheet: View { ScrollView { VStack(spacing: 24) { bunkerSection - nostrConnectSection + if DeveloperSettings.shared.nostrconnectEnabled { + nostrConnectSection + } } .padding(.top, 16) } From 07349059478275bb77459aea7bd9923965045aba Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 18 Apr 2026 23:48:57 -0400 Subject: [PATCH 6/7] fix(connect): observe DeveloperSettings via @State for reactive updates --- Clave/Views/Home/ConnectSheet.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Clave/Views/Home/ConnectSheet.swift b/Clave/Views/Home/ConnectSheet.swift index eb31dcd..7732790 100644 --- a/Clave/Views/Home/ConnectSheet.swift +++ b/Clave/Views/Home/ConnectSheet.swift @@ -11,13 +11,14 @@ struct ConnectSheet: View { @State private var copiedBunker = false @State private var isConnecting = false @State private var connectionError: String? + @State private var devSettings = DeveloperSettings.shared var body: some View { NavigationStack { ScrollView { VStack(spacing: 24) { bunkerSection - if DeveloperSettings.shared.nostrconnectEnabled { + if devSettings.nostrconnectEnabled { nostrConnectSection } } From 8dc96b4c8314fd3b01533bb1d95ca120eb71a4ff Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 19 Apr 2026 04:50:19 -0400 Subject: [PATCH 7/7] chore: bump build to 14 --- Clave.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index ac51fcd..7a642da 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -611,7 +611,7 @@ CODE_SIGN_ENTITLEMENTS = Clave/Clave.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 944AF56S27; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -649,7 +649,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 944AF56S27; ENABLE_PREVIEWS = YES; @@ -685,7 +685,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.4; @@ -707,7 +707,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.4; @@ -728,7 +728,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 0.1.0; @@ -748,7 +748,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 0.1.0; @@ -769,7 +769,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ClaveNSE/ClaveNSE.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ClaveNSE/Info.plist; @@ -800,7 +800,7 @@ CODE_SIGN_ENTITLEMENTS = ClaveNSE/ClaveNSE.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 944AF56S27; GENERATE_INFOPLIST_FILE = YES;