Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions Clave.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -77,6 +81,8 @@
/* Begin PBXFileReference section */
34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnectParser.swift; sourceTree = "<group>"; };
894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientPermissions.swift; sourceTree = "<group>"; };
DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = "<group>"; };
DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -202,6 +208,8 @@
children = (
EF3D7A582F8BD020005A6545 /* Bech32.swift */,
894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */,
DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */,
DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */,
34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */,
EF3D7A592F8BD020005A6545 /* LightCrypto.swift */,
EF3D7A5A2F8BD020005A6545 /* LightEvent.swift */,
Expand Down Expand Up @@ -414,6 +422,8 @@
EF6058BFB67AD8970BBE8953 /* LightEvent.swift in Sources */,
EF1963B89F3A6472380F1E0A /* LightRelay.swift in Sources */,
EF85F076F6C02095C4B6D9C4 /* LightSigner.swift in Sources */,
DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */,
DE7E10C2DE7E10C2DE7E10C2 /* LogExporter.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -446,6 +456,8 @@
EFAF49B35F7A4241A65DCD89 /* SharedStorage.swift in Sources */,
BD247C1AD3A7497E8DF29530 /* ClientPermissions.swift in Sources */,
006B17A84C114EDF9B129CAE /* NostrConnectParser.swift in Sources */,
DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */,
DE7E10C3DE7E10C3DE7E10C3 /* LogExporter.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -599,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;
Expand Down Expand Up @@ -637,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;
Expand Down Expand Up @@ -673,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;
Expand All @@ -695,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;
Expand All @@ -716,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;
Expand All @@ -736,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;
Expand All @@ -757,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;
Expand Down Expand Up @@ -788,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;
Expand Down
5 changes: 4 additions & 1 deletion Clave/Views/Home/ConnectSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ 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
nostrConnectSection
if devSettings.nostrconnectEnabled {
nostrConnectSection
}
}
.padding(.top, 16)
}
Expand Down
64 changes: 64 additions & 0 deletions Clave/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -16,6 +19,9 @@ struct SettingsView: View {
pushProxySection
relaySection
aboutSection
if devSettings.developerMenuUnlocked {
developerSection
}
}
.navigationTitle("Settings")
.onAppear { loadSettings() }
Expand Down Expand Up @@ -160,6 +166,64 @@ 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 {
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
}
}
} 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) {}
}
}

Expand Down
33 changes: 33 additions & 0 deletions ClaveTests/DeveloperSettingsTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
47 changes: 47 additions & 0 deletions ClaveTests/LogExporterFormattingTests.swift
Original file line number Diff line number Diff line change
@@ -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, "")
}
}
37 changes: 37 additions & 0 deletions Shared/DeveloperSettings.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
48 changes: 48 additions & 0 deletions Shared/LogExporter.swift
Original file line number Diff line number Diff line change
@@ -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<String>? = 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<String> for call-site ergonomics.
static func format(entries: [Entry], categories: [String]) -> String {
format(entries: entries, categories: Set(categories))
}
}