Merge pull request #3740 from danieldaquino/gh-3731

Sentry integration
This commit is contained in:
Daniel D’Aquino
2026-05-11 11:34:11 -07:00
committed by GitHub
26 changed files with 2090 additions and 31 deletions
+2
View File
@@ -7,3 +7,5 @@ TODO.bak
tags tags
build-git-hash.txt build-git-hash.txt
.build .build
.sentryclirc
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/damus-nostr-inc/apple-ios"
}
}
}
@@ -11,6 +11,7 @@ import UserNotifications
import Foundation import Foundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
import Intents import Intents
import Sentry
class NotificationService: UNNotificationServiceExtension { class NotificationService: UNNotificationServiceExtension {
@@ -29,6 +30,7 @@ class NotificationService: UNNotificationServiceExtension {
} }
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
DamusSentry.startIfEnabled()
configureKingfisherCache() configureKingfisherCache()
self.contentHandler = contentHandler self.contentHandler = contentHandler
@@ -117,6 +119,12 @@ class NotificationService: UNNotificationServiceExtension {
improvedContent.attachments = [attachment] improvedContent.attachments = [attachment]
} catch { } catch {
Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription) Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "fetch_notification_attachment",
"image_url": sender_profile.picture.absoluteString
], key: "notification_service")
}
} }
let kind = nostr_event.known_kind let kind = nostr_event.known_kind
@@ -146,6 +154,12 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(updated) contentHandler(updated)
} catch { } catch {
Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription) Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "donate_notification_interaction",
"note_kind": nostr_event.kind
], key: "notification_service")
}
contentHandler(improvedContent) contentHandler(improvedContent)
} }
} }
+47 -1
View File
@@ -3,7 +3,44 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSPrivacyCollectedDataTypes</key> <key>NSPrivacyCollectedDataTypes</key>
<array/> <array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePerformanceData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key> <key>NSPrivacyAccessedAPITypes</key>
<array> <array>
<dict> <dict>
@@ -12,6 +49,15 @@
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>1C8F.1</string> <string>1C8F.1</string>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array> </array>
</dict> </dict>
<dict> <dict>
+47 -1
View File
@@ -3,7 +3,44 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSPrivacyCollectedDataTypes</key> <key>NSPrivacyCollectedDataTypes</key>
<array/> <array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePerformanceData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key> <key>NSPrivacyAccessedAPITypes</key>
<array> <array>
<dict> <dict>
@@ -12,6 +49,15 @@
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>1C8F.1</string> <string>1C8F.1</string>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array> </array>
</dict> </dict>
<dict> <dict>
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
if ! command -v sentry-cli >/dev/null 2>&1; then
echo "Installing sentry-cli on Xcode Cloud builder"
SENTRY_CLI_VERSION="3.4.2"
export SENTRY_CLI_VERSION
curl -fsSL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION="$SENTRY_CLI_VERSION" sh
fi
if ! command -v sentry-cli >/dev/null 2>&1; then
echo "warning: sentry-cli not installed, skipping Sentry dSYM upload"
exit 0
fi
if [ -z "${CI_ARCHIVE_PATH:-}" ]; then
echo "warning: CI_ARCHIVE_PATH is not set, skipping Sentry dSYM upload"
exit 0
fi
DSYM_PATH="${CI_ARCHIVE_PATH}/dSYMs"
if [ ! -d "${DSYM_PATH}" ]; then
echo "warning: No dSYMs directory found at ${DSYM_PATH}, skipping Sentry dSYM upload"
exit 0
fi
export SENTRY_ORG=damus-nostr-inc
export SENTRY_PROJECT=apple-ios
if ! sentry-cli debug-files upload --include-sources "${DSYM_PATH}" >/dev/null 2>&1; then
echo "warning: sentry-cli failed to upload dSYMs from ${DSYM_PATH}"
fi
+56
View File
@@ -1093,6 +1093,7 @@
9609F058296E220800069BF3 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; }; 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; };
9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C83F89229A937B900136C08 /* TextViewWrapper.swift */; }; 9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C83F89229A937B900136C08 /* TextViewWrapper.swift */; };
9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; }; 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; };
ABE98D51D7DF44DAAD333D5E /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 5A958478DCE24724BA3B6460 /* Sentry */; };
ADFE73552AD4793100EC7326 /* QRScanNSECView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */; }; ADFE73552AD4793100EC7326 /* QRScanNSECView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */; };
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B501062C2B363036003874F5 /* AuthIntegrationTests.swift */; }; B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B501062C2B363036003874F5 /* AuthIntegrationTests.swift */; };
B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51C1CE82B55A60A00E312A9 /* AddMuteItemView.swift */; }; B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51C1CE82B55A60A00E312A9 /* AddMuteItemView.swift */; };
@@ -1738,6 +1739,7 @@
D77DA2CE2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */; }; D77DA2CE2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */; };
D77DA2D42F1EEB47000B7093 /* NostrRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEE2AEC2805B22500AB5EEF /* NostrRequest.swift */; }; D77DA2D42F1EEB47000B7093 /* NostrRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEE2AEC2805B22500AB5EEF /* NostrRequest.swift */; };
D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; }; D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; };
D7840B782FA28C6500221E4D /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 5A958478DCE24724BA3B6460 /* Sentry */; };
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; }; D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; };
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; };
@@ -1788,6 +1790,18 @@
D798D22C2B086C7400234419 /* NostrEvent+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D798D22B2B086C7400234419 /* NostrEvent+.swift */; }; D798D22C2B086C7400234419 /* NostrEvent+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D798D22B2B086C7400234419 /* NostrEvent+.swift */; };
D798D22D2B086DC400234419 /* NostrEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB228049D640006080F /* NostrEvent.swift */; }; D798D22D2B086DC400234419 /* NostrEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB228049D640006080F /* NostrEvent.swift */; };
D798D22E2B086E4800234419 /* NostrResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB028049D510006080F /* NostrResponse.swift */; }; D798D22E2B086E4800234419 /* NostrResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB028049D510006080F /* NostrResponse.swift */; };
D79C07402FA579DE00BD4BFF /* DamusSentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C073F2FA579D600BD4BFF /* DamusSentry.swift */; };
D79C07412FA579DE00BD4BFF /* DamusSentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C073F2FA579D600BD4BFF /* DamusSentry.swift */; };
D79C07422FA579DE00BD4BFF /* DamusSentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C073F2FA579D600BD4BFF /* DamusSentry.swift */; };
D79C07442FA57BB700BD4BFF /* SensitiveDataScrubbingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07432FA57BB200BD4BFF /* SensitiveDataScrubbingTests.swift */; };
D79C07462FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07452FA69DDB00BD4BFF /* PrivacySettingsView.swift */; };
D79C07472FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07452FA69DDB00BD4BFF /* PrivacySettingsView.swift */; };
D79C07482FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07452FA69DDB00BD4BFF /* PrivacySettingsView.swift */; };
D79C074A2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07492FA6A5AE00BD4BFF /* GlobalSettingsStore.swift */; };
D79C074B2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07492FA6A5AE00BD4BFF /* GlobalSettingsStore.swift */; };
D79C074C2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07492FA6A5AE00BD4BFF /* GlobalSettingsStore.swift */; };
D79C074D2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C07492FA6A5AE00BD4BFF /* GlobalSettingsStore.swift */; };
D79C074E2FA9252600BD4BFF /* DamusSentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C073F2FA579D600BD4BFF /* DamusSentry.swift */; };
D79C4C172AFEB061003A41B4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C4C162AFEB061003A41B4 /* NotificationService.swift */; }; D79C4C172AFEB061003A41B4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C4C162AFEB061003A41B4 /* NotificationService.swift */; };
D79C4C1B2AFEB061003A41B4 /* DamusNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D79C4C1B2AFEB061003A41B4 /* DamusNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */; }; D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */; };
@@ -2900,6 +2914,10 @@
D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; }; D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; };
D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; }; D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; };
D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; }; D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; };
D79C073F2FA579D600BD4BFF /* DamusSentry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusSentry.swift; sourceTree = "<group>"; };
D79C07432FA57BB200BD4BFF /* SensitiveDataScrubbingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensitiveDataScrubbingTests.swift; sourceTree = "<group>"; };
D79C07452FA69DDB00BD4BFF /* PrivacySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsView.swift; sourceTree = "<group>"; };
D79C07492FA6A5AE00BD4BFF /* GlobalSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSettingsStore.swift; sourceTree = "<group>"; };
D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DamusNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DamusNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D79C4C162AFEB061003A41B4 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; D79C4C162AFEB061003A41B4 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
D79C4C182AFEB061003A41B4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D79C4C182AFEB061003A41B4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -2982,6 +3000,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D7840B782FA28C6500221E4D /* Sentry in Frameworks */,
3ACF94382DA9A52F00971A4E /* FaviconFinder in Frameworks */, 3ACF94382DA9A52F00971A4E /* FaviconFinder in Frameworks */,
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */, 4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
D7DB1FE42D5A9AC900CF06DA /* CryptoSwift in Frameworks */, D7DB1FE42D5A9AC900CF06DA /* CryptoSwift in Frameworks */,
@@ -3056,6 +3075,7 @@
D7EDED312B1290B80018B19C /* MarkdownUI in Frameworks */, D7EDED312B1290B80018B19C /* MarkdownUI in Frameworks */,
D7DB1FEA2D5A9F5A00CF06DA /* CryptoSwift in Frameworks */, D7DB1FEA2D5A9F5A00CF06DA /* CryptoSwift in Frameworks */,
D7E5B2DF2EA0A68600CF47AC /* Negentropy in Frameworks */, D7E5B2DF2EA0A68600CF47AC /* Negentropy in Frameworks */,
ABE98D51D7DF44DAAD333D5E /* Sentry in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -3934,6 +3954,7 @@
4CE6DEF627F7A08200C66700 /* damusTests */ = { 4CE6DEF627F7A08200C66700 /* damusTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D79C07432FA57BB200BD4BFF /* SensitiveDataScrubbingTests.swift */,
D7E14B922F68B22D00BF84B7 /* NdbCompactionTests.swift */, D7E14B922F68B22D00BF84B7 /* NdbCompactionTests.swift */,
D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */, D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */,
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */, D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */,
@@ -4559,6 +4580,7 @@
5C78A7912E3036DA00CF177D /* Views */ = { 5C78A7912E3036DA00CF177D /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D79C07452FA69DDB00BD4BFF /* PrivacySettingsView.swift */,
D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */, D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */,
D78778252F49478200DA73E4 /* StorageSettingsView.swift */, D78778252F49478200DA73E4 /* StorageSettingsView.swift */,
4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */, 4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */,
@@ -4646,6 +4668,7 @@
5C78A7992E303C1400CF177D /* Models */ = { 5C78A7992E303C1400CF177D /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D79C07492FA6A5AE00BD4BFF /* GlobalSettingsStore.swift */,
BA693073295D649800ADDB87 /* UserSettingsStore.swift */, BA693073295D649800ADDB87 /* UserSettingsStore.swift */,
); );
path = Models; path = Models;
@@ -4948,6 +4971,7 @@
5C78A7B82E3047DE00CF177D /* Utilities */ = { 5C78A7B82E3047DE00CF177D /* Utilities */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D79C073F2FA579D600BD4BFF /* DamusSentry.swift */,
D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */, D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */,
D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */, D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */,
D7E5B2D22EA0187B00CF47AC /* StreamPipelineDiagnostics.swift */, D7E5B2D22EA0187B00CF47AC /* StreamPipelineDiagnostics.swift */,
@@ -5523,6 +5547,7 @@
D7DB1FE32D5A9AC900CF06DA /* CryptoSwift */, D7DB1FE32D5A9AC900CF06DA /* CryptoSwift */,
3ACF94372DA9A52F00971A4E /* FaviconFinder */, 3ACF94372DA9A52F00971A4E /* FaviconFinder */,
D7E5B2DA2EA080BE00CF47AC /* Negentropy */, D7E5B2DA2EA080BE00CF47AC /* Negentropy */,
5A958478DCE24724BA3B6460 /* Sentry */,
); );
productName = damus; productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */; productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -5740,6 +5765,7 @@
3ACF94362DA9A52F00971A4E /* XCRemoteSwiftPackageReference "FaviconFinder" */, 3ACF94362DA9A52F00971A4E /* XCRemoteSwiftPackageReference "FaviconFinder" */,
D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */, D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */,
D74723EA2F15B0C3002DA12A /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */, D74723EA2F15B0C3002DA12A /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */,
B1B8FE6C91C1401D965BD8DA /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
); );
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */; productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -5847,6 +5873,7 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "git rev-parse --short HEAD > \"${SRCROOT}/build-git-hash.txt\"\n"; shellScript = "git rev-parse --short HEAD > \"${SRCROOT}/build-git-hash.txt\"\n";
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -5955,6 +5982,7 @@
4CA5588329F33F5B00DC6A45 /* StringCodable.swift in Sources */, 4CA5588329F33F5B00DC6A45 /* StringCodable.swift in Sources */,
4C75EFB92804A2740006080F /* EventView.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */,
4C15C7152A55DE7A00D0A0DB /* ReactionsSettingsView.swift in Sources */, 4C15C7152A55DE7A00D0A0DB /* ReactionsSettingsView.swift in Sources */,
D79C074D2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
4C32B94C2A9AD44700DC3548 /* FbConstants.swift in Sources */, 4C32B94C2A9AD44700DC3548 /* FbConstants.swift in Sources */,
4C8D00C829DF791C0036AF10 /* CompatibleAttribute.swift in Sources */, 4C8D00C829DF791C0036AF10 /* CompatibleAttribute.swift in Sources */,
4C687C272A6039500092C550 /* TestData.swift in Sources */, 4C687C272A6039500092C550 /* TestData.swift in Sources */,
@@ -6115,11 +6143,13 @@
4CC7AAF0297F11C700430951 /* SelectedEventView.swift in Sources */, 4CC7AAF0297F11C700430951 /* SelectedEventView.swift in Sources */,
4CC7AAF8297F1CEE00430951 /* EventProfile.swift in Sources */, 4CC7AAF8297F1CEE00430951 /* EventProfile.swift in Sources */,
5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */, 5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */,
D79C07472FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */,
4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */, 4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */,
64FBD06F296255C400D9D3B2 /* Theme.swift in Sources */, 64FBD06F296255C400D9D3B2 /* Theme.swift in Sources */,
D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */, D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */,
4C1A9A2329DDDB8100516EAC /* IconLabel.swift in Sources */, 4C1A9A2329DDDB8100516EAC /* IconLabel.swift in Sources */,
4CA352AC2A76C07F003BB08B /* NewUnmutesNotify.swift in Sources */, 4CA352AC2A76C07F003BB08B /* NewUnmutesNotify.swift in Sources */,
D79C07412FA579DE00BD4BFF /* DamusSentry.swift in Sources */,
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */, D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */,
4CE879522996B68900F758CC /* RelayType.swift in Sources */, 4CE879522996B68900F758CC /* RelayType.swift in Sources */,
4CE8795B2996C47A00F758CC /* ZapsModel.swift in Sources */, 4CE8795B2996C47A00F758CC /* ZapsModel.swift in Sources */,
@@ -6464,6 +6494,7 @@
B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */, B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */,
D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */, D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */,
4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */, 4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */,
D79C07442FA57BB700BD4BFF /* SensitiveDataScrubbingTests.swift in Sources */,
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */, D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */,
D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */, D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */,
4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */,
@@ -6558,6 +6589,7 @@
82D6FAB52CD99F7900C925F4 /* NativeObject.swift in Sources */, 82D6FAB52CD99F7900C925F4 /* NativeObject.swift in Sources */,
82D6FAB62CD99F7900C925F4 /* String+extension.swift in Sources */, 82D6FAB62CD99F7900C925F4 /* String+extension.swift in Sources */,
3A515C552DF5371D002D3B34 /* TrustedNetworkButtonTipViewStyle.swift in Sources */, 3A515C552DF5371D002D3B34 /* TrustedNetworkButtonTipViewStyle.swift in Sources */,
D79C07422FA579DE00BD4BFF /* DamusSentry.swift in Sources */,
82D6FAB72CD99F7900C925F4 /* FlatBufferObject.swift in Sources */, 82D6FAB72CD99F7900C925F4 /* FlatBufferObject.swift in Sources */,
82D6FAB82CD99F7900C925F4 /* Enum.swift in Sources */, 82D6FAB82CD99F7900C925F4 /* Enum.swift in Sources */,
82D6FAB92CD99F7900C925F4 /* builder.c in Sources */, 82D6FAB92CD99F7900C925F4 /* builder.c in Sources */,
@@ -6754,6 +6786,7 @@
82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */, 82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */, D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */, 82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
D79C07482FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */,
82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */, 82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */, 82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
82D6FB6B2CD99F7900C925F4 /* StoreObserver.swift in Sources */, 82D6FB6B2CD99F7900C925F4 /* StoreObserver.swift in Sources */,
@@ -6987,6 +7020,7 @@
82D6FC312CD99F7900C925F4 /* ProxyView.swift in Sources */, 82D6FC312CD99F7900C925F4 /* ProxyView.swift in Sources */,
82D6FC322CD99F7900C925F4 /* SelectedEventView.swift in Sources */, 82D6FC322CD99F7900C925F4 /* SelectedEventView.swift in Sources */,
5C8F97352EB46145009399B1 /* LiveStreamView.swift in Sources */, 5C8F97352EB46145009399B1 /* LiveStreamView.swift in Sources */,
D79C074A2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
82D6FC332CD99F7900C925F4 /* EventBody.swift in Sources */, 82D6FC332CD99F7900C925F4 /* EventBody.swift in Sources */,
D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */, D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */,
82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */, 82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */,
@@ -7334,6 +7368,7 @@
D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */, D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */,
D73E5ED82C6A97F4007EB227 /* DamusVideoPlayerView.swift in Sources */, D73E5ED82C6A97F4007EB227 /* DamusVideoPlayerView.swift in Sources */,
D73E5ED92C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */, D73E5ED92C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */,
D79C07462FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */,
D73E5EDA2C6A97F4007EB227 /* DamusVideoCoordinator.swift in Sources */, D73E5EDA2C6A97F4007EB227 /* DamusVideoCoordinator.swift in Sources */,
D73E5EDC2C6A97F4007EB227 /* ReactionsSettingsView.swift in Sources */, D73E5EDC2C6A97F4007EB227 /* ReactionsSettingsView.swift in Sources */,
D73E5EDD2C6A97F4007EB227 /* NotificationSettingsView.swift in Sources */, D73E5EDD2C6A97F4007EB227 /* NotificationSettingsView.swift in Sources */,
@@ -7560,6 +7595,7 @@
D703D7A92C670E5A00A400EA /* refmap.c in Sources */, D703D7A92C670E5A00A400EA /* refmap.c in Sources */,
D73C7EDC2DE51699001F9392 /* OnboardingContentSettings.swift in Sources */, D73C7EDC2DE51699001F9392 /* OnboardingContentSettings.swift in Sources */,
5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */, 5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */,
D79C07402FA579DE00BD4BFF /* DamusSentry.swift in Sources */,
D703D77B2C670BF000A400EA /* TableVerifier.swift in Sources */, D703D77B2C670BF000A400EA /* TableVerifier.swift in Sources */,
3ACF94442DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */, 3ACF94442DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */,
D703D76D2C670B4500A400EA /* ZapDataModel.swift in Sources */, D703D76D2C670B4500A400EA /* ZapDataModel.swift in Sources */,
@@ -7618,6 +7654,7 @@
D703D7892C670C8600A400EA /* DeepLPlan.swift in Sources */, D703D7892C670C8600A400EA /* DeepLPlan.swift in Sources */,
5CB645AA2EAC01430018BD91 /* DamusLabsExperiments.swift in Sources */, 5CB645AA2EAC01430018BD91 /* DamusLabsExperiments.swift in Sources */,
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */, D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
D79C074C2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */, D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */, D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */, D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
@@ -7741,10 +7778,12 @@
D7CCFC162B05894300323D86 /* Pubkey.swift in Sources */, D7CCFC162B05894300323D86 /* Pubkey.swift in Sources */,
D7EDED2C2B128CFA0018B19C /* DamusColors.swift in Sources */, D7EDED2C2B128CFA0018B19C /* DamusColors.swift in Sources */,
D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */, D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */,
D79C074E2FA9252600BD4BFF /* DamusSentry.swift in Sources */,
D798D2292B08686C00234419 /* ContentParsing.swift in Sources */, D798D2292B08686C00234419 /* ContentParsing.swift in Sources */,
D798D2242B0859C900234419 /* LocalizationUtil.swift in Sources */, D798D2242B0859C900234419 /* LocalizationUtil.swift in Sources */,
D7CE1B322B0BE6C3002EDAD4 /* NdbTxn.swift in Sources */, D7CE1B322B0BE6C3002EDAD4 /* NdbTxn.swift in Sources */,
D7CE1B372B0BE719002EDAD4 /* Verifier.swift in Sources */, D7CE1B372B0BE719002EDAD4 /* Verifier.swift in Sources */,
D79C074B2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
D74AAFC82B155C9D006CF0F4 /* InsertSort.swift in Sources */, D74AAFC82B155C9D006CF0F4 /* InsertSort.swift in Sources */,
D723411A2B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */, D723411A2B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */,
D7EDED292B1182060018B19C /* AttachMediaUtility.swift in Sources */, D7EDED292B1182060018B19C /* AttachMediaUtility.swift in Sources */,
@@ -8131,6 +8170,7 @@
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
DEVELOPMENT_TEAM = XK7H4JAB3D; DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -8185,6 +8225,7 @@
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
DEVELOPMENT_TEAM = XK7H4JAB3D; DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -8609,6 +8650,16 @@
minimumVersion = 0.2.26; minimumVersion = 0.2.26;
}; };
}; };
B1B8FE6C91C1401D965BD8DA /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/getsentry/sentry-cocoa/";
requirement = {
kind = exactVersion;
version = 9.11.0;
};
traits = (
);
};
D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/twostraws/CodeScanner.git"; repositoryURL = "https://github.com/twostraws/CodeScanner.git";
@@ -8708,6 +8759,11 @@
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;
productName = secp256k1; productName = secp256k1;
}; };
5A958478DCE24724BA3B6460 /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = B1B8FE6C91C1401D965BD8DA /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
82D6FC802CD99FC500C925F4 /* secp256k1 */ = { 82D6FC802CD99FC500C925F4 /* secp256k1 */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;
@@ -1,5 +1,5 @@
{ {
"originHash" : "c718c1e7dcc1a07671694b2d7d7311e11804fbbaf22f4b81e49523a3df816ad6", "originHash" : "69116d3b516f6eb000e56fb88ccfca91c324778f3b97dfa2704e221da07b1ca2",
"pins" : [ "pins" : [
{ {
"identity" : "codescanner", "identity" : "codescanner",
@@ -88,6 +88,15 @@
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9" "revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
} }
}, },
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa/",
"state" : {
"revision" : "a49e7c2148ac9e38bd35ef4f13bc9d6ea3ff0b81",
"version" : "9.11.0"
}
},
{ {
"identity" : "swift-collections", "identity" : "swift-collections",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -7,6 +7,7 @@
import Foundation import Foundation
import os import os
import Negentropy import Negentropy
import Sentry
extension NostrNetworkManager { extension NostrNetworkManager {
@@ -272,6 +273,13 @@ extension NostrNetworkManager {
} }
catch { catch {
Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)") Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)")
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "network_subscription_stream",
"subscription_id": id.uuidString,
"filter_count": filters.count
], key: "network")
}
} }
Self.logger.debug("Network subscription \(id.uuidString, privacy: .public): Network streaming ended") Self.logger.debug("Network subscription \(id.uuidString, privacy: .public): Network streaming ended")
continuation.finish() continuation.finish()
@@ -308,6 +316,13 @@ extension NostrNetworkManager {
} }
catch { catch {
Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)") Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)")
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "negentropy_subscription_stream",
"subscription_id": id.uuidString,
"filter_count": filters.count
], key: "network")
}
} }
}) })
} }
+13
View File
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Network import Network
import Negentropy import Negentropy
import Sentry
struct RelayHandler { struct RelayHandler {
let sub_id: String let sub_id: String
@@ -313,6 +314,12 @@ class RelayPool {
#if DEBUG #if DEBUG
print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)") print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)")
#endif #endif
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "add_ephemeral_relay",
"relay_url": url.absoluteString
], key: "relay_pool")
}
} }
} }
@@ -886,6 +893,12 @@ class RelayPool {
Log.error("Error while negentropy streaming: %s", for: .networking, error.localizedDescription) Log.error("Error while negentropy streaming: %s", for: .networking, error.localizedDescription)
} }
else { else {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "negentropy_sync_multi_relay",
"ignore_unsupported": ignoreUnsupportedRelays
], key: "relay_pool")
}
throw error throw error
} }
} }
@@ -7,6 +7,7 @@
import Foundation import Foundation
import OSLog import OSLog
import Sentry
/// Manages periodic snapshots of the main NostrDB database to a shared container location. /// Manages periodic snapshots of the main NostrDB database to a shared container location.
/// ///
@@ -155,6 +156,12 @@ actor DatabaseSnapshotManager {
do { do {
try fileManager.createDirectory(atPath: tempSnapshotPath.path, withIntermediateDirectories: true) try fileManager.createDirectory(atPath: tempSnapshotPath.path, withIntermediateDirectories: true)
} catch { } catch {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "create_temp_snapshot_directory",
"path": tempSnapshotPath.path
], key: "snapshot")
}
throw SnapshotError.directoryCreationFailed(error) throw SnapshotError.directoryCreationFailed(error)
} }
@@ -165,6 +172,12 @@ actor DatabaseSnapshotManager {
try fileManager.removeItem(atPath: tempSnapshotPath.path) try fileManager.removeItem(atPath: tempSnapshotPath.path)
} catch { } catch {
Log.error("Failed to cleanup temporary snapshot directory: %{public}@", for: .storage, error.localizedDescription) Log.error("Failed to cleanup temporary snapshot directory: %{public}@", for: .storage, error.localizedDescription)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "cleanup_temp_snapshot_directory",
"path": tempSnapshotPath.path
], key: "snapshot")
}
} }
} }
} }
@@ -173,7 +186,14 @@ actor DatabaseSnapshotManager {
// Create a new Ndb instance in the temporary directory // Create a new Ndb instance in the temporary directory
guard let snapshotNdb = Ndb(path: tempSnapshotPath.path, owns_db_file: true) else { guard let snapshotNdb = Ndb(path: tempSnapshotPath.path, owns_db_file: true) else {
throw SnapshotError.failedToCreateSnapshotDatabase let error = SnapshotError.failedToCreateSnapshotDatabase
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "create_snapshot_ndb",
"path": tempSnapshotPath.path
], key: "snapshot")
}
throw error
} }
defer { defer {
@@ -307,6 +327,12 @@ actor DatabaseSnapshotManager {
do { do {
try fileManager.createDirectory(atPath: parentDir, withIntermediateDirectories: true) try fileManager.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch { } catch {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "create_parent_directory",
"path": parentDir
], key: "snapshot")
}
throw SnapshotError.directoryCreationFailed(error) throw SnapshotError.directoryCreationFailed(error)
} }
} }
@@ -321,6 +347,14 @@ actor DatabaseSnapshotManager {
Log.debug("Moved snapshot from %{public}@ to %{public}@", for: .storage, tempPath, finalPath) Log.debug("Moved snapshot from %{public}@ to %{public}@", for: .storage, tempPath, finalPath)
} catch { } catch {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "move_snapshot_to_final_destination",
"temp_path": tempPath,
"final_path": finalPath,
"file_exists": fileManager.fileExists(atPath: finalPath)
], key: "snapshot")
}
throw SnapshotError.moveFailed(error) throw SnapshotError.moveFailed(error)
} }
} }
+24 -1
View File
@@ -4,6 +4,7 @@
// //
import SwiftUI import SwiftUI
import Sentry
/// A view that handles database compaction on a background thread before showing the main content. /// A view that handles database compaction on a background thread before showing the main content.
/// ///
@@ -45,6 +46,11 @@ struct CompactionView: View {
@State private var compactionState: CompactionState = .idle @State private var compactionState: CompactionState = .idle
@State private var hasStartedCompactionFlow: Bool = false @State private var hasStartedCompactionFlow: Bool = false
/// The source of the compaction request (automatic scheduling or manual user action).
/// Used to determine error handling behavior: automatic compactions report to Sentry silently,
/// while manual compactions show errors to the user.
@State private var compactionSource: NdbCompactionRequestSource = .automatic
var body: some View { var body: some View {
Group { Group {
@@ -92,6 +98,9 @@ struct CompactionView: View {
compactionState = .complete compactionState = .complete
return return
} }
// Determine if this is a manual or automatic compaction request
compactionSource = Ndb.get_compact_on_next_launch_source() ?? .automatic
let showsLargeDatabaseWarning = Ndb.db_path.map({ Ndb.is_large_database(path: $0) }) ?? false let showsLargeDatabaseWarning = Ndb.db_path.map({ Ndb.is_large_database(path: $0) }) ?? false
compactionState = CompactionState.initialCompactingState(showsLargeDatabaseWarning: showsLargeDatabaseWarning) compactionState = CompactionState.initialCompactingState(showsLargeDatabaseWarning: showsLargeDatabaseWarning)
@@ -116,7 +125,21 @@ struct CompactionView: View {
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
compactionState = .failed(error: error.localizedDescription) // Always send errors to Sentry with compaction source context
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"compaction_source": compactionSource.rawValue,
"error_description": error.localizedDescription
], key: "compaction")
}
// For automatic compaction, silently continue
// For manual compaction, show the error UI to the user
if compactionSource == .automatic {
compactionState = .complete
} else {
compactionState = .failed(error: error.localizedDescription)
}
} }
} }
} }
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import Sentry
// Minimum threshold the hellthread pubkey tag count setting can go down to. // Minimum threshold the hellthread pubkey tag count setting can go down to.
let HELLTHREAD_MIN_PUBKEYS: Int = 6 let HELLTHREAD_MIN_PUBKEYS: Int = 6
@@ -55,7 +56,15 @@ struct PushNotificationClient {
Log.info("Sent device token to Damus push notification server successfully", for: .push_notifications) Log.info("Sent device token to Damus push notification server successfully", for: .push_notifications)
default: default:
Log.error("Error in sending device_token to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.error("Error in sending device_token to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) let error = ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "send_device_token",
"status_code": httpResponse.statusCode,
"data_length": data.count
], key: "push_notifications")
}
throw error
} }
} }
@@ -90,7 +99,15 @@ struct PushNotificationClient {
Log.info("Sent device token removal request to Damus push notification server successfully", for: .push_notifications) Log.info("Sent device token removal request to Damus push notification server successfully", for: .push_notifications)
default: default:
Log.error("Error in sending device_token removal to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.error("Error in sending device_token removal to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) let error = ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "revoke_device_token",
"status_code": httpResponse.statusCode,
"data_length": data.count
], key: "push_notifications")
}
throw error
} }
} }
@@ -125,7 +142,15 @@ struct PushNotificationClient {
Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications) Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications)
default: default:
Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) let error = ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "set_notification_settings",
"status_code": httpResponse.statusCode,
"data_length": data.count
], key: "push_notifications")
}
throw error
} }
} }
@@ -155,11 +180,28 @@ struct PushNotificationClient {
if let httpResponse = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode { switch httpResponse.statusCode {
case 200: case 200:
guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error } guard let notification_settings = NotificationSettings.from(json_data: data) else {
let error = ClientError.json_decoding_error
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "get_notification_settings_decode",
"data_length": data.count
], key: "push_notifications")
}
throw error
}
return notification_settings return notification_settings
default: default:
Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) let error = ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "get_notification_settings",
"status_code": httpResponse.statusCode,
"data_length": data.count
], key: "push_notifications")
}
throw error
} }
} }
throw ClientError.could_not_process_response throw ClientError.could_not_process_response
+33 -8
View File
@@ -7,6 +7,7 @@
import Foundation import Foundation
import StoreKit import StoreKit
import Sentry
class DamusPurple: StoreObserverDelegate { class DamusPurple: StoreObserverDelegate {
let settings: UserSettingsStore let settings: UserSettingsStore
@@ -151,11 +152,19 @@ class DamusPurple: StoreObserverDelegate {
if let httpResponse = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode { switch httpResponse.statusCode {
case 200: case 200:
Log.info("Got user UUID from Damus Purple server", for: .damus_purple) Log.info("Successfully fetched account UUID from Damus Purple server", for: .damus_purple)
default: default:
Log.error("Error in getting user UUID with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.error("Error fetching account UUID from Damus Purple server. Status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "")
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data) let error = PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "fetch_uuid_for_account",
"status_code": httpResponse.statusCode,
"response": String(data: data, encoding: .utf8) ?? "Unknown"
], key: "damus_purple")
}
throw error
} }
} }
@@ -193,7 +202,15 @@ class DamusPurple: StoreObserverDelegate {
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple) Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
default: default:
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data) let error = DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "send_iap_receipt",
"status_code": httpResponse.statusCode,
"response": String(data: data, encoding: .utf8) ?? "Unknown"
], key: "damus_purple")
}
throw error
} }
} }
} }
@@ -219,8 +236,16 @@ class DamusPurple: StoreObserverDelegate {
case 200: case 200:
Log.info("Sent transaction ID to Damus Purple server and activated successfully", for: .damus_purple) Log.info("Sent transaction ID to Damus Purple server and activated successfully", for: .damus_purple)
default: default:
Log.error("Error in sending or verifying transaction ID with Damus Purple server. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data) let error = DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "send_transaction_id",
"status_code": httpResponse.statusCode,
"response": String(data: data, encoding: .utf8) ?? "Unknown"
], key: "damus_purple")
}
throw error
} }
} }
} }
@@ -0,0 +1,53 @@
//
// GlobalSettingsStore.swift
// damus
//
// Created for device-level settings that apply globally across all accounts
//
import Foundation
import Combine
/// A property wrapper for global (non-pubkey-scoped) settings.
/// Use this for device-level preferences that should apply regardless of which account is logged in.
@propertyWrapper struct GlobalSetting<T: Equatable> {
private let key: String
private var value: T
init(key: String, default_value: T) {
if T.self == Bool.self {
GlobalSettingsStore.bool_options.insert(key)
}
self.key = key
self.value = DamusUserDefaults.standard.object(forKey: key) as? T ?? default_value
}
var wrappedValue: T {
get { return value }
set {
guard value != newValue else { return }
self.value = newValue
DamusUserDefaults.standard.set(newValue, forKey: key)
DispatchQueue.main.async {
GlobalSettingsStore.shared.objectWillChange.send()
}
}
}
}
/// Store for global (device-level) settings that apply regardless of which account is logged in.
///
/// Unlike `UserSettingsStore`, these settings are not scoped to a specific pubkey.
/// Use this for device preferences like privacy settings, accessibility options, etc.
class GlobalSettingsStore: ObservableObject {
static let shared = GlobalSettingsStore()
static var bool_options = Set<String>()
private init() {}
/// Controls whether Sentry telemetry and error reporting is enabled.
/// When disabled, no data is sent to Sentry for crash reporting or diagnostics.
/// This is a global setting that applies to all accounts on this device.
@GlobalSetting(key: "enable_sentry_telemetry", default_value: false)
var enable_sentry_telemetry: Bool
}
@@ -25,6 +25,7 @@ struct ConfigView: View {
// String constants // String constants
private let DELETE_KEYWORD = "DELETE" private let DELETE_KEYWORD = "DELETE"
private let keysTitle = NSLocalizedString("Keys", comment: "Settings section for managing keys") private let keysTitle = NSLocalizedString("Keys", comment: "Settings section for managing keys")
private let privacyTitle = NSLocalizedString("Privacy", comment: "Settings section for privacy and data collection settings")
private let appearanceTitle = NSLocalizedString("Appearance and filters", comment: "Section header for text, appearance, and content filter settings") private let appearanceTitle = NSLocalizedString("Appearance and filters", comment: "Section header for text, appearance, and content filter settings")
private let searchUniverseTitle = NSLocalizedString("Search / Universe", comment: "Section header for search/universe settings") private let searchUniverseTitle = NSLocalizedString("Search / Universe", comment: "Section header for search/universe settings")
private let notificationsTitle = NSLocalizedString("Notifications", comment: "Section header for Damus notifications") private let notificationsTitle = NSLocalizedString("Notifications", comment: "Section header for Damus notifications")
@@ -62,6 +63,12 @@ struct ConfigView: View {
IconLabel(keysTitle,img_name:"Key",color:.purple) IconLabel(keysTitle,img_name:"Key",color:.purple)
} }
} }
// Privacy
if showSettingsButton(title: privacyTitle){
NavigationLink(value: Route.PrivacySettings){
IconLabel(privacyTitle, img_name: "eye-off.fill", color:.blue)
}
}
// Appearance and filters // Appearance and filters
if showSettingsButton(title: appearanceTitle){ if showSettingsButton(title: appearanceTitle){
NavigationLink(value:Route.AppearanceSettings(settings: settings)){ NavigationLink(value:Route.AppearanceSettings(settings: settings)){
@@ -0,0 +1,165 @@
//
// PrivacySettingsView.swift
// damus
//
// Created for privacy and data collection settings
//
import Foundation
import SwiftUI
import Sentry
struct PrivacySettingsView: View {
@ObservedObject var globalSettings = GlobalSettingsStore.shared
@State private var isShowingPrivacyDetails = false
var body: some View {
Form {
Section(
header: Text("Error & Diagnostics Reporting", comment: "Section header for error and diagnostics reporting settings"),
footer: VStack(alignment: .leading, spacing: 8) {
Text("Sentry provides reporting for crashes, errors, and app health issues. When enabled, anonymous technical data is sent to help improve the app. No personal information or private keys are collected. This setting applies to the entire app on this device and is not tied to a specific account. Restart the app for changes to take effect.", comment: "Footer explaining Sentry telemetry")
Button(NSLocalizedString("Learn more", comment: "Button title to show more details about privacy and data handling")) {
isShowingPrivacyDetails = true
}
.font(.footnote)
}
) {
Toggle(NSLocalizedString("Enable Error & Diagnostics Reporting", comment: "Setting to enable Sentry error and diagnostics reporting"), isOn: $globalSettings.enable_sentry_telemetry)
.toggleStyle(.switch)
}
}
.navigationTitle(NSLocalizedString("Privacy", comment: "Navigation title for Privacy settings"))
.sheet(isPresented: $isShowingPrivacyDetails) {
PrivacyDataHandlingDetailsView()
}
}
}
/// Presents additional context about diagnostics reporting and the data shared with Sentry.
struct PrivacyDataHandlingDetailsView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
Text("When diagnostics reporting is enabled, Damus sends anonymous technical information to Sentry so crashes, errors, and app stability problems can be investigated and fixed faster.", comment: "Overview of diagnostics reporting and why it exists")
} header: {
Text("How it works", comment: "Section title describing how diagnostics reporting works")
}
Section {
PrivacyInfoRow(
icon: "chart.bar.xaxis",
title: NSLocalizedString("What is shared", comment: "Title for information about data that is shared"),
description: NSLocalizedString("Crash reports, error messages, app version, iOS version, device model, and basic performance diagnostics.", comment: "Description of shared diagnostics data")
)
PrivacyInfoRow(
icon: "hand.raised.fill",
title: NSLocalizedString("What is not shared", comment: "Title for information about data that is not shared"),
description: NSLocalizedString("Private keys, seed phrases, wallet secrets, direct message contents, and profile content you create in the app.", comment: "Description of private data that is not shared")
)
PrivacyInfoRow(
icon: "switch.2",
title: NSLocalizedString("Your control", comment: "Title for information about user control over diagnostics reporting"),
description: NSLocalizedString("You can turn diagnostics reporting on or off at any time from Privacy settings. Changes apply after restarting the app. Turning it off stops future reports from being sent, but does not delete reports that were already sent.", comment: "Description of user control over diagnostics reporting")
)
} header: {
Text("Data handling details", comment: "Section title for detailed data handling information")
}
Section {
CollectedDataRow(
title: NSLocalizedString("Crash and exception details", comment: "Title for crash and exception details row"),
description: NSLocalizedString("Technical stack traces and failure context used to debug app issues.", comment: "Description for crash and exception details row"),
collected: true
)
CollectedDataRow(
title: NSLocalizedString("Device and app metadata", comment: "Title for device and app metadata row"),
description: NSLocalizedString("Information such as app version, operating system version, and device type.", comment: "Description for device and app metadata row"),
collected: true
)
CollectedDataRow(
title: NSLocalizedString("Messages, notes, and private keys", comment: "Title for sensitive data row"),
description: NSLocalizedString("Personal content and credentials are not intentionally collected for diagnostics reporting. Damus is designed not to send sensitive information such as private keys, public keys, or email addresses, and it also automatically detects and redacts those patterns as an additional safeguard before reports are sent.", comment: "Description for sensitive data row"),
collected: false
)
} header: {
Text("Collected data summary", comment: "Section title summarizing collected data")
}
}
.navigationTitle(NSLocalizedString("Diagnostics Privacy", comment: "Navigation title for the privacy details sheet"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(NSLocalizedString("Done", comment: "Button to dismiss privacy details sheet")) {
dismiss()
}
}
}
}
}
}
// MARK: - Supporting Views
struct PrivacyInfoRow: View {
let icon: String
let title: String
let description: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundColor(.green)
.font(.system(size: 20))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
Text(description)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
struct CollectedDataRow: View {
let title: String
let description: String
let collected: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: collected ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(collected ? .blue : .gray)
.font(.system(size: 20))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
Text(description)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
// MARK: - Preview
struct PrivacySettingsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
PrivacySettingsView()
}
}
}
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import Sentry
/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key. /// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
/// ///
@@ -95,7 +96,15 @@ class CoinosDeterministicAccountClient {
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
return return
} else { } else {
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data) let error = ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "coinos_register",
"status_code": (response as? HTTPURLResponse)?.statusCode ?? -1,
"response_length": data.count
], key: "wallet")
}
throw error
} }
} }
@@ -128,7 +137,16 @@ class CoinosDeterministicAccountClient {
switch httpResponse.statusCode { switch httpResponse.statusCode {
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data) case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
case 401: throw ClientError.unauthorized case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data) default:
let error = ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "coinos_login",
"status_code": httpResponse.statusCode,
"response_length": data.count
], key: "wallet")
}
throw error
} }
} }
throw ClientError.errorProcessingResponse throw ClientError.errorProcessingResponse
@@ -159,10 +177,27 @@ class CoinosDeterministicAccountClient {
if let httpResponse = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode { switch httpResponse.statusCode {
case 200: case 200:
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse } guard let nwc = try await self.getNWCUrl() else {
let error = ClientError.errorProcessingResponse
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "create_nwc_connection_get_url"
], key: "wallet")
}
throw error
}
return nwc return nwc
case 401: throw ClientError.unauthorized case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data) default:
let error = ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "create_nwc_connection",
"status_code": httpResponse.statusCode,
"response_length": data.count
], key: "wallet")
}
throw error
} }
} }
throw ClientError.errorProcessingResponse throw ClientError.errorProcessingResponse
@@ -203,10 +238,28 @@ class CoinosDeterministicAccountClient {
if let httpResponse = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode { switch httpResponse.statusCode {
case 200: case 200:
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse } guard let nwc = try await self.getNWCUrl() else {
let error = ClientError.errorProcessingResponse
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "update_nwc_connection_get_url"
], key: "wallet")
}
throw error
}
return nwc return nwc
case 401: throw ClientError.unauthorized case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data) default:
let error = ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "update_nwc_connection",
"status_code": httpResponse.statusCode,
"max_amount": maxAmount,
"response_length": data.count
], key: "wallet")
}
throw error
} }
} }
throw ClientError.errorProcessingResponse throw ClientError.errorProcessingResponse
@@ -72,6 +72,25 @@ extension WalletConnect {
// MARK: - Interface // MARK: - Interface
/// Returns a privacy-safe string representing only the type of request, without exposing sensitive details
/// like invoice strings, amounts, or timestamps.
///
/// This is intended for logging and error reporting to external services like Sentry.
var requestTypeForLogging: String {
return methodForLogging.rawValue
}
private var methodForLogging: Method {
switch self {
case .payInvoice:
return .payInvoice
case .getBalance:
return .getBalance
case .getTransactionList:
return .listTransactions
}
}
/// Converts the NWC request into a raw Nostr event to be sent in the network /// Converts the NWC request into a raw Nostr event to be sent in the network
/// ///
/// - Parameters: /// - Parameters:
+101 -7
View File
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import Sentry
enum WalletConnectState { enum WalletConnectState {
case new(WalletConnectURL) case new(WalletConnectURL)
@@ -100,6 +101,13 @@ class WalletModel: ObservableObject {
/// - Parameter response: The NWC response received from the network /// - Parameter response: The NWC response received from the network
func handle_nwc_response(response: WalletConnect.FullWalletResponse) { func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
if let error = response.response.error { if let error = response.response.error {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "nwc_response_error",
"error_code": error.code,
"error_message": error.message ?? "No message"
], key: "wallet")
}
self.resume(request: response.req_id, throwing: error) self.resume(request: response.req_id, throwing: error)
return return
} }
@@ -148,7 +156,20 @@ class WalletModel: ObservableObject {
func fetchTransactions(from: UInt64?, until: UInt64?, limit: Int?, offset: Int?, unpaid: Bool?, type: String?) async throws -> [WalletConnect.Transaction] { func fetchTransactions(from: UInt64?, until: UInt64?, limit: Int?, offset: Int?, unpaid: Bool?, type: String?) async throws -> [WalletConnect.Transaction] {
let response = try await self.request(.getTransactionList(from: from, until: until, limit: limit, offset: offset, unpaid: unpaid, type: type)) let response = try await self.request(.getTransactionList(from: from, until: until, limit: limit, offset: offset, unpaid: unpaid, type: type))
guard case .list_transactions(let transactionResponse) = response else { throw FetchError.responseMismatch } let safeResponseType = response.safeTypeForLogging
guard case .list_transactions(let transactionResponse) = response else {
let error = FetchError.responseMismatch
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "fetch_transactions_response_mismatch",
"response_type": safeResponseType,
"from": from as Any,
"until": until as Any,
"limit": limit as Any
], key: "wallet")
}
throw error
}
return transactionResponse.transactions return transactionResponse.transactions
} }
@@ -156,7 +177,17 @@ class WalletModel: ObservableObject {
/// Fetches the balance amount from the network and returns the amount in sats /// Fetches the balance amount from the network and returns the amount in sats
func fetchBalance() async throws -> Int64 { func fetchBalance() async throws -> Int64 {
let response = try await self.request(.getBalance) let response = try await self.request(.getBalance)
guard case .get_balance(let balanceResponse) = response else { throw FetchError.responseMismatch } let safeResponseType = response.safeTypeForLogging
guard case .get_balance(let balanceResponse) = response else {
let error = FetchError.responseMismatch
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "fetch_balance_response_mismatch",
"response_type": safeResponseType
], key: "wallet")
}
throw error
}
return balanceResponse.balance / 1000 return balanceResponse.balance / 1000
} }
@@ -169,7 +200,15 @@ class WalletModel: ObservableObject {
func request(_ request: WalletConnect.Request, timeout: Duration = .seconds(10)) async throws(WalletRequestError) -> WalletConnect.Response.Result { func request(_ request: WalletConnect.Request, timeout: Duration = .seconds(10)) async throws(WalletRequestError) -> WalletConnect.Response.Result {
guard let nostrNetwork else { throw .notConnectedToTheNostrNetwork } guard let nostrNetwork else { throw .notConnectedToTheNostrNetwork }
guard let currentNwcUrl = self.connect_state.currentNwcUrl() else { throw .noConnectedWallet } guard let currentNwcUrl = self.connect_state.currentNwcUrl() else { throw .noConnectedWallet }
guard let requestEvent = request.to_nostr_event(to_pk: currentNwcUrl.pubkey, keypair: currentNwcUrl.keypair) else { throw .errorFormattingRequest } guard let requestEvent = request.to_nostr_event(to_pk: currentNwcUrl.pubkey, keypair: currentNwcUrl.keypair) else {
DamusSentry.captureSentryMessage("Wallet request failed: error formatting request") { scope in
scope.setContext(value: [
"operation": "wallet_request_format",
"request_type": request.requestTypeForLogging
], key: "wallet")
}
throw .errorFormattingRequest
}
let responseFilters = [ let responseFilters = [
NostrFilter( NostrFilter(
@@ -182,19 +221,60 @@ class WalletModel: ObservableObject {
await nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false) await nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false)
for await event in nostrNetwork.reader.timedStream(filters: responseFilters, to: [currentNwcUrl.relay], timeout: timeout) { for await event in nostrNetwork.reader.timedStream(filters: responseFilters, to: [currentNwcUrl.relay], timeout: timeout) {
guard let responseEvent = try? event.getCopy() else { throw .internalError } let responseEvent: NdbNote
do {
responseEvent = try event.getCopy()
} catch {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "wallet_response_get_copy",
"error_type": String(describing: type(of: error))
], key: "wallet")
}
throw .internalError
}
let fullWalletResponse: WalletConnect.FullWalletResponse let fullWalletResponse: WalletConnect.FullWalletResponse
do { fullWalletResponse = try WalletConnect.FullWalletResponse(from: responseEvent, nwc: currentNwcUrl) } do { fullWalletResponse = try WalletConnect.FullWalletResponse(from: responseEvent, nwc: currentNwcUrl) }
catch { throw WalletRequestError.walletResponseDecodingError(error) } catch {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "wallet_response_decode",
"error_type": String(describing: type(of: error))
], key: "wallet")
}
throw WalletRequestError.walletResponseDecodingError(error)
}
guard fullWalletResponse.req_id == requestEvent.id else { continue } // Our filters may match other responses guard fullWalletResponse.req_id == requestEvent.id else { continue } // Our filters may match other responses
if let responseError = fullWalletResponse.response.error { throw .walletResponseError(responseError) } if let responseError = fullWalletResponse.response.error {
DamusSentry.captureSentryError(responseError) { scope in
scope.setContext(value: [
"operation": "wallet_response_error",
"error_code": responseError.code,
"error_message": responseError.message ?? "No message"
], key: "wallet")
}
throw .walletResponseError(responseError)
}
guard let result = fullWalletResponse.response.result else { throw .walletEmptyResponse } guard let result = fullWalletResponse.response.result else {
DamusSentry.captureSentryMessage("Wallet response has no result") { scope in
scope.setContext(value: [
"operation": "wallet_empty_response",
], key: "wallet")
}
throw .walletEmptyResponse
}
return result return result
} }
do { try Task.checkCancellation() } catch { throw .cancelled } do { try Task.checkCancellation() } catch { throw .cancelled }
DamusSentry.captureSentryMessage("Wallet request timeout") { scope in
scope.setContext(value: [
"operation": "wallet_request_timeout",
"request_type": request.requestTypeForLogging,
], key: "wallet")
}
throw .responseTimeout throw .responseTimeout
} }
@@ -247,3 +327,17 @@ class WalletModel: ObservableObject {
case timeout case timeout
} }
} }
private extension WalletConnect.Response.Result {
/// Returns a privacy-safe label describing only the response case for telemetry.
var safeTypeForLogging: String {
switch self {
case .get_balance:
return "get_balance"
case .pay_invoice:
return "pay_invoice"
case .list_transactions:
return "list_transactions"
}
}
}
@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Sentry
enum ZappingEventType { enum ZappingEventType {
case failed(ZappingError) case failed(ZappingError)
@@ -184,6 +185,14 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else { guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
// this should never happen // this should never happen
DamusSentry.captureSentryMessage("Failed to create zap request event") { scope in
scope.setContext(value: [
"operation": "make_zap_request_event",
"zap_type": String(describing: zap_type),
"relay_count": relays.count,
"has_comment": comment != nil
], key: "zaps")
}
return return
} }
@@ -203,6 +212,11 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
let typ = ZappingEventType.failed(.bad_lnurl) let typ = ZappingEventType.failed(.bad_lnurl)
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target) let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
notify(.zapping(ev)) notify(.zapping(ev))
DamusSentry.captureSentryMessage("Failed to fetch lnurl payreq for zap") { scope in
scope.setContext(value: [
"operation": "zap_fetch_lnurl"
], key: "zaps")
}
return return
} }
@@ -211,6 +225,12 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
let typ = ZappingEventType.failed(.fetching_invoice) let typ = ZappingEventType.failed(.fetching_invoice)
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target) let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
notify(.zapping(ev)) notify(.zapping(ev))
DamusSentry.captureSentryMessage("Failed to fetch zap invoice") { scope in
scope.setContext(value: [
"operation": "zap_fetch_invoice",
"zap_type": String(describing: zap_type)
], key: "zaps")
}
return return
} }
@@ -248,6 +268,11 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
let typ = ZappingEventType.failed(.send_failed) let typ = ZappingEventType.failed(.send_failed)
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target) let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
notify(.zapping(ev)) notify(.zapping(ev))
DamusSentry.captureSentryMessage("Failed to send NWC zap request") { scope in
scope.setContext(value: [
"operation": "nwc_pay_request",
], key: "zaps")
}
return return
} }
+498
View File
@@ -0,0 +1,498 @@
//
// DamusSentry.swift
// damus
//
import Sentry
/// Namespace for Damus Sentry startup, capture helpers, and sensitive-data scrubbing.
///
/// This type centralizes Sentry configuration during app startup and provides
/// privacy-preserving helpers used before telemetry leaves the device.
struct DamusSentry {
/// Returns the Sentry environment name for the current build configuration.
///
/// Debug builds are tagged separately so local development traffic does not
/// get grouped together with production events in Sentry.
///
/// - Returns: The Sentry environment name for the current build.
static func environmentName() -> String {
#if DEBUG
return "debug"
#else
return "production"
#endif
}
/// Starts Sentry with Damus-specific configuration when telemetry is enabled.
///
/// This centralizes Sentry startup so the app entry point stays small and all
/// telemetry-related configuration lives in one place.
static func startIfEnabled() {
guard GlobalSettingsStore.shared.enable_sentry_telemetry else {
Log.info("Sentry telemetry disabled by user preference", for: .app_lifecycle)
return
}
SentrySDK.start { options in
options.dsn = "https://9be03ae55b9c1b55feb599f470772deb@o4511304644558848.ingest.us.sentry.io/4511304645738496"
options.environment = environmentName()
// Adds IP for users.
// For more information, visit: https://docs.sentry.io/platforms/apple/data-management/data-collected/
options.sendDefaultPii = false
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
options.tracesSampleRate = 0.5
// Configure profiling. Visit https://docs.sentry.io/platforms/apple/profiling/ to learn more.
options.configureProfiling = {
$0.sessionSampleRate = 0.5
$0.lifecycle = .trace
}
// Scrub sensitive data before sending to Sentry.
options.beforeSend = { event in
return scrubSensitiveData(in: event)
}
options.beforeBreadcrumb = { breadcrumb in
return scrubSensitiveDataInBreadcrumb(breadcrumb)
}
options.beforeSendSpan = { span in
return scrubSensitiveDataInSpan(span)
}
// Uncomment the following lines to add more data to your events
// options.attachScreenshot = true // This adds a screenshot to the error events
// options.attachViewHierarchy = true // This adds the view hierarchy to the error events
// Enable experimental logging features
// options.experimental.enableLogs = true
}
}
/// Safely captures an error to Sentry only if the user has enabled telemetry.
///
/// This is a convenience wrapper around `SentrySDK.capture(error:)` that respects
/// the user's privacy preference from GlobalSettingsStore.
///
/// - Parameters:
/// - error: The error to capture
/// - scopeBlock: Optional block to configure the scope for this capture
static func captureSentryError(_ error: Error, scopeBlock: ((Scope) -> Void)? = nil) {
guard GlobalSettingsStore.shared.enable_sentry_telemetry else { return }
if let scopeBlock = scopeBlock {
SentrySDK.capture(error: error, block: scopeBlock)
} else {
SentrySDK.capture(error: error)
}
}
/// Safely captures a message to Sentry only if the user has enabled telemetry.
///
/// This is a convenience wrapper around `SentrySDK.capture(message:)` that respects
/// the user's privacy preference from GlobalSettingsStore.
///
/// - Parameters:
/// - message: The message to capture
/// - scopeBlock: Optional block to configure the scope for this capture
static func captureSentryMessage(_ message: String, scopeBlock: ((Scope) -> Void)? = nil) {
guard GlobalSettingsStore.shared.enable_sentry_telemetry else { return }
if let scopeBlock = scopeBlock {
SentrySDK.capture(message: message, block: scopeBlock)
} else {
SentrySDK.capture(message: message)
}
}
/// Scrubs sensitive data from Sentry events before they are sent.
///
/// This method removes or redacts:
/// - Nostr private keys (nsec1...)
/// - Nostr public keys (npub1...)
/// - Nostr note IDs (note1...)
/// - Nostr note events with hints (nevent1...)
/// - Nostr profiles with hints (nprofile1...)
/// - Nostr addressable events (naddr1...)
/// - Nostr relay references (nrelay1...)
/// - Hex-encoded keys (64-character hex strings)
/// - Email addresses
///
/// - Parameter event: The Sentry event to scrub
/// - Returns: The scrubbed event, or nil to discard the event
static func scrubSensitiveData(in event: Event) -> Event? {
// Scrub message - create new SentryMessage with scrubbed content
if let messageText = event.message?.formatted {
let scrubbedText = scrubString(messageText)
event.message = SentryMessage(formatted: scrubbedText)
}
// Scrub exception messages and values
if let exceptions = event.exceptions {
for exception in exceptions {
if let value = exception.value {
exception.value = scrubString(value)
}
if let mechanism = exception.mechanism, let description = mechanism.desc {
mechanism.desc = scrubString(description)
}
// Scrub stack trace
if let stacktrace = exception.stacktrace {
scrubStacktrace(stacktrace)
}
}
}
// Scrub threads
if let threads = event.threads {
for thread in threads {
if let stacktrace = thread.stacktrace {
scrubStacktrace(stacktrace)
}
}
}
// Scrub context data
if var context = event.context as? [String: Any] {
scrubDictionary(&context)
event.context = context as? [String: [String: Any]]
}
// Scrub extra data
if var extra = event.extra {
scrubDictionary(&extra)
event.extra = extra
}
// Scrub tags
if let tags = event.tags {
for (key, value) in tags {
event.tags?[key] = scrubString(value)
}
}
// Scrub user data
if let user = event.user {
if let email = user.email {
user.email = scrubEmail(email)
}
if let userId = user.userId {
user.userId = scrubString(userId)
}
if let username = user.username {
user.username = scrubString(username)
}
if var data = user.data {
scrubDictionary(&data)
user.data = data
}
}
// Scrub request data
if let request = event.request {
if let url = request.url {
request.url = scrubString(url)
}
if let queryString = request.queryString {
request.queryString = scrubString(queryString)
}
if var headers = request.headers as? [String: Any] {
scrubDictionary(&headers)
request.headers = headers as? [String: String]
}
}
return event
}
/// Scrubs sensitive data from Sentry breadcrumbs.
///
/// - Parameter breadcrumb: The breadcrumb to scrub
/// - Returns: The scrubbed breadcrumb, or nil to discard it
static func scrubSensitiveDataInBreadcrumb(_ breadcrumb: Breadcrumb) -> Breadcrumb? {
// Scrub message
if let message = breadcrumb.message {
breadcrumb.message = scrubString(message)
}
// Scrub data
if var data = breadcrumb.data {
scrubDictionary(&data)
breadcrumb.data = data
}
return breadcrumb
}
/// Scrubs sensitive data from Sentry spans.
///
/// Spans represent units of work in performance tracing (e.g., network requests, database queries).
/// If a span contains sensitive data, it's dropped entirely rather than sent with potentially unscrubbed data.
///
/// - Parameter span: The span to scrub
/// - Returns: The scrubbed span, or nil to discard it
static func scrubSensitiveDataInSpan(_ span: Span) -> Span? {
// Check span description for sensitive data
if let spanDescription = span.spanDescription {
if containsSensitiveData(spanDescription) {
// Drop the entire span if it contains sensitive data
return nil
}
}
// Check span data for sensitive data (read-only, can't scrub)
if let data = span.data as? [String: Any] {
if containsSensitiveDataInDictionary(data) {
return nil
}
}
// Check span tags for sensitive data (read-only, can't scrub)
for (_, value) in span.tags {
if containsSensitiveData(value) {
return nil
}
}
return span
}
/// Checks if a string contains sensitive data patterns.
///
/// - Parameter string: The string to check
/// - Returns: True if the string contains sensitive data
private static func containsSensitiveData(_ string: String) -> Bool {
// Check for nsec (private keys)
if string.range(of: "nsec1[a-zA-Z0-9]{58,}", options: .regularExpression) != nil {
return true
}
// Check for npub (public keys)
if string.range(of: "npub1[a-zA-Z0-9]{58,}", options: .regularExpression) != nil {
return true
}
// Check for note IDs
if string.range(of: "note1[a-zA-Z0-9]{58,}", options: .regularExpression) != nil {
return true
}
// Check for nevent
if string.range(of: "nevent1[a-zA-Z0-9]{58,}", options: .regularExpression) != nil {
return true
}
// Check for nprofile
if string.range(of: "nprofile1[a-zA-Z0-9]{58,}", options: .regularExpression) != nil {
return true
}
// Check for naddr
if string.range(of: "naddr1[a-zA-Z0-9]{58,}", options: .regularExpression) != nil {
return true
}
// Check for nrelay
if string.range(of: "nrelay1[a-zA-Z0-9]+", options: .regularExpression) != nil {
return true
}
// Check for hex keys (64-character hex strings)
if string.range(of: "\\b[0-9a-fA-F]{64}\\b", options: .regularExpression) != nil {
return true
}
// Check for email addresses
if string.range(of: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", options: .regularExpression) != nil {
return true
}
return false
}
/// Checks if a dictionary contains sensitive data.
///
/// - Parameter dictionary: The dictionary to check
/// - Returns: True if the dictionary contains sensitive data
private static func containsSensitiveDataInDictionary(_ dictionary: [String: Any]) -> Bool {
for (_, value) in dictionary {
if let stringValue = value as? String {
if containsSensitiveData(stringValue) {
return true
}
} else if let nestedDict = value as? [String: Any] {
if containsSensitiveDataInDictionary(nestedDict) {
return true
}
} else if let array = value as? [Any] {
if containsSensitiveDataInArray(array) {
return true
}
}
}
return false
}
/// Checks if an array contains sensitive data.
///
/// - Parameter array: The array to check
/// - Returns: True if the array contains sensitive data
private static func containsSensitiveDataInArray(_ array: [Any]) -> Bool {
for item in array {
if let stringValue = item as? String {
if containsSensitiveData(stringValue) {
return true
}
} else if let dictValue = item as? [String: Any] {
if containsSensitiveDataInDictionary(dictValue) {
return true
}
} else if let arrayValue = item as? [Any] {
if containsSensitiveDataInArray(arrayValue) {
return true
}
}
}
return false
}
/// Scrubs sensitive patterns from a string.
///
/// - Parameter string: The string to scrub
/// - Returns: The scrubbed string with sensitive data redacted
private static func scrubString(_ string: String) -> String {
var scrubbed = string
// Scrub nsec (private keys) - most critical
scrubbed = scrubbed.replacingOccurrences(
of: "nsec1[a-zA-Z0-9]{58,}",
with: "[REDACTED_NSEC]",
options: .regularExpression
)
// Scrub npub (public keys)
scrubbed = scrubbed.replacingOccurrences(
of: "npub1[a-zA-Z0-9]{58,}",
with: "[REDACTED_NPUB]",
options: .regularExpression
)
// Scrub note IDs
scrubbed = scrubbed.replacingOccurrences(
of: "note1[a-zA-Z0-9]{58,}",
with: "[REDACTED_NOTE]",
options: .regularExpression
)
// Scrub nevent (note events with relay hints)
scrubbed = scrubbed.replacingOccurrences(
of: "nevent1[a-zA-Z0-9]{58,}",
with: "[REDACTED_NEVENT]",
options: .regularExpression
)
// Scrub nprofile (profiles with relay hints)
scrubbed = scrubbed.replacingOccurrences(
of: "nprofile1[a-zA-Z0-9]{58,}",
with: "[REDACTED_NPROFILE]",
options: .regularExpression
)
// Scrub naddr (addressable/replaceable events)
scrubbed = scrubbed.replacingOccurrences(
of: "naddr1[a-zA-Z0-9]{58,}",
with: "[REDACTED_NADDR]",
options: .regularExpression
)
// Scrub nrelay (relay references)
scrubbed = scrubbed.replacingOccurrences(
of: "nrelay1[a-zA-Z0-9]+",
with: "[REDACTED_NRELAY]",
options: .regularExpression
)
// Scrub hex keys (64-character hex strings that might be keys)
// Use word boundaries to avoid matching legitimate hex in other contexts
scrubbed = scrubbed.replacingOccurrences(
of: "\\b[0-9a-fA-F]{64}\\b",
with: "[REDACTED_HEX]",
options: .regularExpression
)
// Scrub email addresses
scrubbed = scrubEmail(scrubbed)
return scrubbed
}
/// Scrubs email addresses from a string.
///
/// - Parameter string: The string to scrub
/// - Returns: The string with email addresses redacted
private static func scrubEmail(_ string: String) -> String {
return string.replacingOccurrences(
of: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
with: "[REDACTED_EMAIL]",
options: .regularExpression
)
}
/// Scrubs sensitive data from a dictionary recursively.
///
/// - Parameter dictionary: The dictionary to scrub (modified in place)
private static func scrubDictionary(_ dictionary: inout [String: Any]) {
for (key, value) in dictionary {
if let stringValue = value as? String {
dictionary[key] = scrubString(stringValue)
} else if var nestedDict = value as? [String: Any] {
scrubDictionary(&nestedDict)
dictionary[key] = nestedDict
} else if let array = value as? [Any] {
dictionary[key] = scrubArray(array)
}
}
}
/// Scrubs sensitive data from an array recursively.
///
/// - Parameter array: The array to scrub
/// - Returns: A new array with scrubbed values
private static func scrubArray(_ array: [Any]) -> [Any] {
return array.map { item in
if let stringValue = item as? String {
return scrubString(stringValue)
} else if var dictValue = item as? [String: Any] {
scrubDictionary(&dictValue)
return dictValue
} else if let arrayValue = item as? [Any] {
return scrubArray(arrayValue)
}
return item
}
}
/// Scrubs sensitive data from a stacktrace.
///
/// - Parameter stacktrace: The stacktrace to scrub (modified in place)
private static func scrubStacktrace(_ stacktrace: SentryStacktrace) {
let frames = stacktrace.frames
for frame in frames {
// Scrub function names
if let function = frame.function {
frame.function = scrubString(function)
}
// Scrub file paths (might contain sensitive info in URLs)
if let fileName = frame.fileName {
frame.fileName = scrubString(fileName)
}
// Scrub local variables
if var vars = frame.vars {
scrubDictionary(&vars)
frame.vars = vars
}
}
}
}
+5
View File
@@ -24,6 +24,7 @@ enum Route: Hashable {
case DMChat(dms: DirectMessageModel) case DMChat(dms: DirectMessageModel)
case UserRelays(relays: [RelayURL]) case UserRelays(relays: [RelayURL])
case KeySettings(keypair: Keypair) case KeySettings(keypair: Keypair)
case PrivacySettings
case AppearanceSettings(settings: UserSettingsStore) case AppearanceSettings(settings: UserSettingsStore)
case NotificationSettings(settings: UserSettingsStore) case NotificationSettings(settings: UserSettingsStore)
case ZapSettings(settings: UserSettingsStore) case ZapSettings(settings: UserSettingsStore)
@@ -86,6 +87,8 @@ enum Route: Hashable {
UserRelaysView(state: damusState, relays: relays) UserRelaysView(state: damusState, relays: relays)
case .KeySettings(let keypair): case .KeySettings(let keypair):
KeySettingsView(keypair: keypair) KeySettingsView(keypair: keypair)
case .PrivacySettings:
PrivacySettingsView()
case .AppearanceSettings(let settings): case .AppearanceSettings(let settings):
AppearanceSettingsView(damus_state: damusState, settings: settings) AppearanceSettingsView(damus_state: damusState, settings: settings)
case .NotificationSettings(let settings): case .NotificationSettings(let settings):
@@ -194,6 +197,8 @@ enum Route: Hashable {
case .KeySettings(let keypair): case .KeySettings(let keypair):
hasher.combine("keySettings") hasher.combine("keySettings")
hasher.combine(keypair.pubkey) hasher.combine(keypair.pubkey)
case .PrivacySettings:
hasher.combine("privacySettings")
case .AppearanceSettings: case .AppearanceSettings:
hasher.combine("appearanceSettings") hasher.combine("appearanceSettings")
case .NotificationSettings: case .NotificationSettings:
+4
View File
@@ -6,6 +6,8 @@
// //
import Kingfisher import Kingfisher
import Sentry
import SwiftUI import SwiftUI
import StoreKit import StoreKit
@@ -77,6 +79,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
var state: DamusState? = nil var state: DamusState? = nil
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
DamusSentry.startIfEnabled()
UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().delegate = self
SKPaymentQueue.default().add(StoreObserver.standard) SKPaymentQueue.default().add(StoreObserver.standard)
registerNotificationCategories() registerNotificationCategories()
@@ -0,0 +1,761 @@
//
// SensitiveDataScrubbingTests.swift
// damus
//
import XCTest
import Sentry
@testable import damus
final class SensitiveDataScrubbingTests: XCTestCase {
// MARK: - Test String Scrubbing
func testScrubNsecPrivateKey() {
let event = Event()
event.message = SentryMessage(formatted: "User logged in with nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NSEC]"))
XCTAssertFalse(message.contains("nsec1qqqq"))
}
func testScrubNpubPublicKey() {
let event = Event()
event.message = SentryMessage(formatted: "Profile: npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NPUB]"))
XCTAssertFalse(message.contains("npub1abc123"))
}
func testScrubNoteId() {
let event = Event()
event.message = SentryMessage(formatted: "Viewing note note1xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NOTE]"))
XCTAssertFalse(message.contains("note1xyz789"))
}
func testScrubNevent() {
let event = Event()
event.message = SentryMessage(formatted: "Event reference: nevent1qqsxyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NEVENT]"))
XCTAssertFalse(message.contains("nevent1qqsxyz"))
}
func testScrubNprofile() {
let event = Event()
event.message = SentryMessage(formatted: "Profile link: nprofile1qqsxyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NPROFILE]"))
XCTAssertFalse(message.contains("nprofile1qqsxyz"))
}
func testScrubNaddr() {
let event = Event()
event.message = SentryMessage(formatted: "Address: naddr1qqsxyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NADDR]"))
XCTAssertFalse(message.contains("naddr1qqsxyz"))
}
func testScrubNrelay() {
let event = Event()
event.message = SentryMessage(formatted: "Relay: nrelay1qqsxyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NRELAY]"))
XCTAssertFalse(message.contains("nrelay1qqsxyz"))
}
func testScrubShortNrelay() {
let event = Event()
event.message = SentryMessage(formatted: "Relay: nrelay1abc123")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NRELAY]"))
XCTAssertFalse(message.contains("nrelay1abc123"))
}
func testScrubHexKey() {
let event = Event()
event.message = SentryMessage(formatted: "Key: 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_HEX]"))
XCTAssertFalse(message.contains("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"))
}
func testScrubEmailAddress() {
let event = Event()
event.message = SentryMessage(formatted: "Contact user@example.com for support")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_EMAIL]"))
XCTAssertFalse(message.contains("user@example.com"))
}
func testScrubMultipleSensitivePatterns() {
let event = Event()
event.message = SentryMessage(formatted: "User nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq with npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123 and nevent1qqsxyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789 emailed test@test.com")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NSEC]"))
XCTAssertTrue(message.contains("[REDACTED_NPUB]"))
XCTAssertTrue(message.contains("[REDACTED_NEVENT]"))
XCTAssertTrue(message.contains("[REDACTED_EMAIL]"))
XCTAssertFalse(message.contains("nsec1qqqq"))
XCTAssertFalse(message.contains("npub1abc123"))
XCTAssertFalse(message.contains("nevent1qqsxyz"))
XCTAssertFalse(message.contains("test@test.com"))
}
// MARK: - Test Exception Scrubbing
func testScrubExceptionValue() {
let event = Event()
let sentryException = Exception(value: "Error: npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123", type: "Error")
event.exceptions = [sentryException]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.exceptions)
let exceptionValue = scrubbedEvent?.exceptions?.first?.value ?? ""
XCTAssertTrue(exceptionValue.contains("[REDACTED_NPUB]"))
XCTAssertFalse(exceptionValue.contains("npub1abc123"))
}
func testScrubExceptionMechanismDescription() {
let event = Event()
let sentryException = Exception(value: "Error", type: "Error")
let mechanism = Mechanism(type: "generic")
mechanism.desc = "Failure for nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
sentryException.mechanism = mechanism
event.exceptions = [sentryException]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
let mechanismDescription = scrubbedEvent?.exceptions?.first?.mechanism?.desc ?? ""
XCTAssertTrue(mechanismDescription.contains("[REDACTED_NSEC]"))
XCTAssertFalse(mechanismDescription.contains("nsec1qqqq"))
}
// MARK: - Test Context Scrubbing
func testScrubContextData() {
let event = Event()
event.context = [
"nostr": [
"pubkey": "npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123",
"email": "context@example.com",
"nested": [
"private_key": "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
"hex": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
]
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
guard let nostrContext = scrubbedEvent?.context?["nostr"] else {
XCTFail("Expected nostr context")
return
}
XCTAssertEqual(nostrContext["pubkey"] as? String, "[REDACTED_NPUB]")
XCTAssertEqual(nostrContext["email"] as? String, "[REDACTED_EMAIL]")
guard let nested = nostrContext["nested"] as? [String: Any] else {
XCTFail("Expected nested context")
return
}
XCTAssertEqual(nested["private_key"] as? String, "[REDACTED_NSEC]")
XCTAssertEqual(nested["hex"] as? String, "[REDACTED_HEX]")
}
// MARK: - Test Tag Scrubbing
func testScrubTags() {
let event = Event()
event.tags = [
"user_id": "npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123",
"key": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.tags)
let userId = scrubbedEvent?.tags?["user_id"] ?? ""
let key = scrubbedEvent?.tags?["key"] ?? ""
XCTAssertTrue(userId.contains("[REDACTED_NPUB]"))
XCTAssertTrue(key.contains("[REDACTED_HEX]"))
XCTAssertFalse(userId.contains("npub1abc123"))
XCTAssertFalse(key.contains("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"))
}
// MARK: - Test User Data Scrubbing
func testScrubUserEmail() {
let event = Event()
let user = User()
user.email = "sensitive@example.com"
user.userId = "npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123"
user.username = "user@domain.com"
event.user = user
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.user)
XCTAssertTrue(scrubbedEvent?.user?.email?.contains("[REDACTED_EMAIL]") ?? false)
XCTAssertTrue(scrubbedEvent?.user?.userId?.contains("[REDACTED_NPUB]") ?? false)
XCTAssertTrue(scrubbedEvent?.user?.username?.contains("[REDACTED_EMAIL]") ?? false)
}
func testScrubUserData() {
let event = Event()
let user = User()
user.data = [
"private_key": "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
"email": "test@example.com"
]
event.user = user
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.user?.data)
let privateKey = scrubbedEvent?.user?.data?["private_key"] as? String
let email = scrubbedEvent?.user?.data?["email"] as? String
XCTAssertTrue(privateKey?.contains("[REDACTED_NSEC]") ?? false)
XCTAssertTrue(email?.contains("[REDACTED_EMAIL]") ?? false)
}
// MARK: - Test Breadcrumb Scrubbing
func testScrubBreadcrumbMessage() {
let breadcrumb = Breadcrumb()
breadcrumb.message = "User logged in with nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
let scrubbedBreadcrumb = DamusSentry.scrubSensitiveDataInBreadcrumb(breadcrumb)
XCTAssertNotNil(scrubbedBreadcrumb?.message)
XCTAssertTrue(scrubbedBreadcrumb?.message?.contains("[REDACTED_NSEC]") ?? false)
XCTAssertFalse(scrubbedBreadcrumb?.message?.contains("nsec1") ?? true)
}
func testScrubBreadcrumbData() {
let breadcrumb = Breadcrumb()
breadcrumb.data = [
"pubkey": "npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123",
"email": "user@example.com"
]
let scrubbedBreadcrumb = DamusSentry.scrubSensitiveDataInBreadcrumb(breadcrumb)
XCTAssertNotNil(scrubbedBreadcrumb?.data)
let pubkey = scrubbedBreadcrumb?.data?["pubkey"] as? String
let email = scrubbedBreadcrumb?.data?["email"] as? String
XCTAssertTrue(pubkey?.contains("[REDACTED_NPUB]") ?? false)
XCTAssertTrue(email?.contains("[REDACTED_EMAIL]") ?? false)
}
func testScrubBreadcrumbShortNrelay() {
let breadcrumb = Breadcrumb()
breadcrumb.message = "Relay: nrelay1abc123"
let scrubbedBreadcrumb = DamusSentry.scrubSensitiveDataInBreadcrumb(breadcrumb)
XCTAssertNotNil(scrubbedBreadcrumb?.message)
XCTAssertTrue(scrubbedBreadcrumb?.message?.contains("[REDACTED_NRELAY]") ?? false)
XCTAssertFalse(scrubbedBreadcrumb?.message?.contains("nrelay1abc123") ?? true)
}
// MARK: - Test Nested Dictionary Scrubbing
func testScrubNestedDictionary() {
let event = Event()
event.extra = [
"level1": [
"level2": [
"key": "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
"email": "nested@example.com"
]
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let level1 = scrubbedEvent?.extra?["level1"] as? [String: Any],
let level2 = level1["level2"] as? [String: Any] {
let key = level2["key"] as? String
let email = level2["email"] as? String
XCTAssertTrue(key?.contains("[REDACTED_NSEC]") ?? false)
XCTAssertTrue(email?.contains("[REDACTED_EMAIL]") ?? false)
} else {
XCTFail("Nested dictionary not properly structured")
}
}
// MARK: - Test Array Scrubbing
func testScrubArrayOfStrings() {
let event = Event()
event.extra = [
"keys": [
"nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
"npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123",
"user@example.com"
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let keys = scrubbedEvent?.extra?["keys"] as? [String] {
XCTAssertTrue(keys[0].contains("[REDACTED_NSEC]"))
XCTAssertTrue(keys[1].contains("[REDACTED_NPUB]"))
XCTAssertTrue(keys[2].contains("[REDACTED_EMAIL]"))
} else {
XCTFail("Array not properly scrubbed")
}
}
// MARK: - Test Edge Cases
func testDoesNotScrubNonSensitiveData() {
let event = Event()
event.message = SentryMessage(formatted: "This is a normal message with no sensitive data")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
XCTAssertEqual(scrubbedEvent?.message?.formatted, "This is a normal message with no sensitive data")
}
func testDoesNotScrubShortHexStrings() {
let event = Event()
event.message = SentryMessage(formatted: "Color: #FF5733 is nice")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
// Should not scrub short hex strings
XCTAssertTrue(message.contains("FF5733"))
}
func testScrubIncompleteEmailPattern() {
let event = Event()
event.message = SentryMessage(formatted: "Contact: user@example.io")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
// This should be scrubbed as it matches the email pattern
XCTAssertTrue(message.contains("[REDACTED_EMAIL]"))
XCTAssertFalse(message.contains("user@example.io"))
}
func testReturnsEventWhenNoSensitiveData() {
let event = Event()
event.message = SentryMessage(formatted: "Normal log message")
event.tags = ["environment": "production"]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent)
XCTAssertEqual(scrubbedEvent?.message?.formatted, "Normal log message")
XCTAssertEqual(scrubbedEvent?.tags?["environment"], "production")
}
// MARK: - Test containsSensitiveDataInDictionary
func testContainsSensitiveDataInDictionaryDetectsSensitiveString() {
// Test via event extra which internally uses containsSensitiveDataInDictionary
let event = Event()
event.extra = [
"user_key": "npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123",
"safe_data": "normal value"
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let userKey = scrubbedEvent?.extra?["user_key"] as? String {
XCTAssertTrue(userKey.contains("[REDACTED_NPUB]"))
}
}
func testContainsSensitiveDataInDictionaryNestedDictionary() {
let event = Event()
event.extra = [
"level1": [
"level2": [
"secret": "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
]
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let level1 = scrubbedEvent?.extra?["level1"] as? [String: Any],
let level2 = level1["level2"] as? [String: Any],
let secret = level2["secret"] as? String {
XCTAssertTrue(secret.contains("[REDACTED_NSEC]"))
} else {
XCTFail("Nested dictionary should be scrubbed")
}
}
func testContainsSensitiveDataInDictionaryWithArray() {
let event = Event()
event.extra = [
"items": [
"safe_value",
"user@example.com",
"another_safe_value"
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let items = scrubbedEvent?.extra?["items"] as? [String] {
XCTAssertEqual(items[0], "safe_value")
XCTAssertTrue(items[1].contains("[REDACTED_EMAIL]"))
XCTAssertEqual(items[2], "another_safe_value")
} else {
XCTFail("Array should be scrubbed")
}
}
// MARK: - Test containsSensitiveDataInArray
func testContainsSensitiveDataInArrayDetectsSensitiveString() {
let event = Event()
event.extra = [
"keys": [
"nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
"clean_value"
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let keys = scrubbedEvent?.extra?["keys"] as? [String] {
XCTAssertTrue(keys[0].contains("[REDACTED_NSEC]"))
XCTAssertEqual(keys[1], "clean_value")
} else {
XCTFail("Array with sensitive data should be scrubbed")
}
}
func testContainsSensitiveDataInArrayNestedDictionary() {
let event = Event()
event.extra = [
"users": [
["name": "Alice", "email": "alice@example.com"],
["name": "Bob", "id": "safe_id"]
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let users = scrubbedEvent?.extra?["users"] as? [[String: String]] {
XCTAssertEqual(users[0]["name"], "Alice")
XCTAssertTrue(users[0]["email"]?.contains("[REDACTED_EMAIL]") ?? false)
XCTAssertEqual(users[1]["name"], "Bob")
XCTAssertEqual(users[1]["id"], "safe_id")
} else {
XCTFail("Array with nested dictionaries should be scrubbed")
}
}
func testContainsSensitiveDataInArrayNestedArray() {
let event = Event()
event.extra = [
"matrix": [
["safe1", "safe2"],
["3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "safe3"]
]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let matrix = scrubbedEvent?.extra?["matrix"] as? [[String]] {
XCTAssertEqual(matrix[0][0], "safe1")
XCTAssertEqual(matrix[0][1], "safe2")
XCTAssertTrue(matrix[1][0].contains("[REDACTED_HEX]"))
XCTAssertEqual(matrix[1][1], "safe3")
} else {
XCTFail("Nested array should be scrubbed")
}
}
func testContainsSensitiveDataInArrayAllCleanValues() {
let event = Event()
event.extra = [
"items": ["value1", "value2", "value3"]
]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.extra)
if let items = scrubbedEvent?.extra?["items"] as? [String] {
XCTAssertEqual(items, ["value1", "value2", "value3"])
} else {
XCTFail("Clean array should remain unchanged")
}
}
// MARK: - Test scrubStacktrace
func testScrubStacktraceFunction() {
let event = Event()
let exception = Exception(value: "Test error", type: "TestException")
let frame1 = Frame()
frame1.function = "loginWithKey(nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq)"
let frame2 = Frame()
frame2.function = "processUser(user@example.com)"
let stacktrace = SentryStacktrace(frames: [frame1, frame2], registers: [:])
exception.stacktrace = stacktrace
event.exceptions = [exception]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.exceptions)
if let frames = scrubbedEvent?.exceptions?.first?.stacktrace?.frames {
XCTAssertEqual(frames.count, 2)
XCTAssertTrue(frames[0].function?.contains("[REDACTED_NSEC]") ?? false)
XCTAssertFalse(frames[0].function?.contains("nsec1") ?? true)
XCTAssertTrue(frames[1].function?.contains("[REDACTED_EMAIL]") ?? false)
XCTAssertFalse(frames[1].function?.contains("user@example.com") ?? true)
} else {
XCTFail("Stacktrace frames should be scrubbed")
}
}
func testScrubStacktraceFileName() {
let event = Event()
let exception = Exception(value: "Test error", type: "TestException")
let frame = Frame()
frame.function = "testFunction"
frame.fileName = "UserProfile_npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123.swift"
let stacktrace = SentryStacktrace(frames: [frame], registers: [:])
exception.stacktrace = stacktrace
event.exceptions = [exception]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.exceptions)
if let frames = scrubbedEvent?.exceptions?.first?.stacktrace?.frames {
XCTAssertEqual(frames.count, 1)
XCTAssertTrue(frames[0].fileName?.contains("[REDACTED_NPUB]") ?? false)
XCTAssertFalse(frames[0].fileName?.contains("npub1abc123") ?? true)
} else {
XCTFail("Stacktrace filename should be scrubbed")
}
}
func testScrubStacktraceVars() {
let event = Event()
let exception = Exception(value: "Test error", type: "TestException")
let frame = Frame()
frame.function = "processData"
frame.vars = [
"userKey": "npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123",
"email": "test@example.com",
"count": "5"
]
let stacktrace = SentryStacktrace(frames: [frame], registers: [:])
exception.stacktrace = stacktrace
event.exceptions = [exception]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.exceptions)
if let frames = scrubbedEvent?.exceptions?.first?.stacktrace?.frames,
let vars = frames[0].vars {
XCTAssertTrue((vars["userKey"] as? String)?.contains("[REDACTED_NPUB]") ?? false)
XCTAssertTrue((vars["email"] as? String)?.contains("[REDACTED_EMAIL]") ?? false)
XCTAssertEqual(vars["count"] as? String, "5")
} else {
XCTFail("Stacktrace vars should be scrubbed")
}
}
func testScrubStacktraceMultipleFrames() {
let event = Event()
let exception = Exception(value: "Test error", type: "TestException")
let frame1 = Frame()
frame1.function = "topLevel"
frame1.vars = ["key": "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"]
let frame2 = Frame()
frame2.function = "middleLevel with 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d hex"
frame2.fileName = "path/to/NormalFile.swift"
let frame3 = Frame()
frame3.function = "bottomLevel(email: admin@example.com)"
let stacktrace = SentryStacktrace(frames: [frame1, frame2, frame3], registers: [:])
exception.stacktrace = stacktrace
event.exceptions = [exception]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.exceptions)
if let frames = scrubbedEvent?.exceptions?.first?.stacktrace?.frames {
XCTAssertEqual(frames.count, 3)
// Check frame 1
let frame1Key = frames[0].vars?["key"] as? String
XCTAssertNotNil(frame1Key, "Frame 1 should have vars with key")
XCTAssertTrue(frame1Key?.contains("[REDACTED_NSEC]") ?? false, "Frame 1 key should be redacted, got: \(frame1Key ?? "nil")")
// Check frame 2
let frame2Function = frames[1].function
XCTAssertNotNil(frame2Function, "Frame 2 should have function")
XCTAssertTrue(frame2Function?.contains("[REDACTED_HEX]") ?? false, "Frame 2 function should be redacted, got: \(frame2Function ?? "nil")")
// Check frame 3
let frame3Function = frames[2].function
XCTAssertNotNil(frame3Function, "Frame 3 should have function")
XCTAssertTrue(frame3Function?.contains("[REDACTED_EMAIL]") ?? false, "Frame 3 function should be redacted, got: \(frame3Function ?? "nil")")
} else {
XCTFail("Multiple stacktrace frames should be scrubbed")
}
}
func testScrubStacktraceInThread() {
let event = Event()
let thread = SentryThread(threadId: 1)
let frame = Frame()
frame.function = "authenticate(nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq)"
let stacktrace = SentryStacktrace(frames: [frame], registers: [:])
thread.stacktrace = stacktrace
event.threads = [thread]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.threads)
if let threads = scrubbedEvent?.threads,
let frames = threads.first?.stacktrace?.frames {
XCTAssertTrue(frames[0].function?.contains("[REDACTED_NSEC]") ?? false)
XCTAssertFalse(frames[0].function?.contains("nsec1") ?? true)
} else {
XCTFail("Thread stacktrace should be scrubbed")
}
}
func testScrubStacktraceEmptyVars() {
let event = Event()
let exception = Exception(value: "Test error", type: "TestException")
let frame = Frame()
frame.function = "testFunction"
frame.vars = [:] // Empty vars
let stacktrace = SentryStacktrace(frames: [frame], registers: [:])
exception.stacktrace = stacktrace
event.exceptions = [exception]
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.exceptions)
// Should not crash with empty vars
XCTAssertNotNil(scrubbedEvent?.exceptions?.first?.stacktrace?.frames)
}
// MARK: - Test Real-world Scenarios
func testScrubComplexErrorMessage() {
let event = Event()
event.message = SentryMessage(formatted: """
Authentication failed for user npub1abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123
Private key: nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq
Hex ID: 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d
Contact: support@damus.io
Note: note1xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7
Event: nevent1qqsxyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789
Profile: nprofile1qqsxyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789
""")
let scrubbedEvent = DamusSentry.scrubSensitiveData(in: event)
XCTAssertNotNil(scrubbedEvent?.message?.formatted)
let message = scrubbedEvent?.message?.formatted ?? ""
XCTAssertTrue(message.contains("[REDACTED_NPUB]"))
XCTAssertTrue(message.contains("[REDACTED_NSEC]"))
XCTAssertTrue(message.contains("[REDACTED_HEX]"))
XCTAssertTrue(message.contains("[REDACTED_EMAIL]"))
XCTAssertTrue(message.contains("[REDACTED_NOTE]"))
XCTAssertTrue(message.contains("[REDACTED_NEVENT]"))
XCTAssertTrue(message.contains("[REDACTED_NPROFILE]"))
// Ensure original sensitive data is not present
XCTAssertFalse(message.contains("npub1abc"))
XCTAssertFalse(message.contains("nsec1qqq"))
XCTAssertFalse(message.contains("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"))
XCTAssertFalse(message.contains("support@damus.io"))
XCTAssertFalse(message.contains("note1xyz"))
XCTAssertFalse(message.contains("nevent1qqsxyz"))
XCTAssertFalse(message.contains("nprofile1qqsxyz"))
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"Sentry": {
"type": "remote",
"url": "https://mcp.sentry.dev/mcp/damus-nostr-inc/apple-ios",
"oauth": {}
}
}
}