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
build-git-hash.txt
.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 UniformTypeIdentifiers
import Intents
import Sentry
class NotificationService: UNNotificationServiceExtension {
@@ -29,6 +30,7 @@ class NotificationService: UNNotificationServiceExtension {
}
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
DamusSentry.startIfEnabled()
configureKingfisherCache()
self.contentHandler = contentHandler
@@ -117,6 +119,12 @@ class NotificationService: UNNotificationServiceExtension {
improvedContent.attachments = [attachment]
} catch {
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
@@ -146,6 +154,12 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(updated)
} catch {
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)
}
}
+47 -1
View File
@@ -3,7 +3,44 @@
<plist version="1.0">
<dict>
<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>
<array>
<dict>
@@ -12,6 +49,15 @@
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<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>
</dict>
<dict>
+47 -1
View File
@@ -3,7 +3,44 @@
<plist version="1.0">
<dict>
<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>
<array>
<dict>
@@ -12,6 +49,15 @@
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<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>
</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 */; };
9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C83F89229A937B900136C08 /* TextViewWrapper.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 */; };
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B501062C2B363036003874F5 /* AuthIntegrationTests.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 */; };
D77DA2D42F1EEB47000B7093 /* NostrRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEE2AEC2805B22500AB5EEF /* NostrRequest.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 */; };
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.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 */; };
D798D22D2B086DC400234419 /* NostrEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB228049D640006080F /* NostrEvent.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 */; };
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 */; };
@@ -2900,6 +2914,10 @@
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>"; };
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; };
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>"; };
@@ -2982,6 +3000,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D7840B782FA28C6500221E4D /* Sentry in Frameworks */,
3ACF94382DA9A52F00971A4E /* FaviconFinder in Frameworks */,
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
D7DB1FE42D5A9AC900CF06DA /* CryptoSwift in Frameworks */,
@@ -3056,6 +3075,7 @@
D7EDED312B1290B80018B19C /* MarkdownUI in Frameworks */,
D7DB1FEA2D5A9F5A00CF06DA /* CryptoSwift in Frameworks */,
D7E5B2DF2EA0A68600CF47AC /* Negentropy in Frameworks */,
ABE98D51D7DF44DAAD333D5E /* Sentry in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3934,6 +3954,7 @@
4CE6DEF627F7A08200C66700 /* damusTests */ = {
isa = PBXGroup;
children = (
D79C07432FA57BB200BD4BFF /* SensitiveDataScrubbingTests.swift */,
D7E14B922F68B22D00BF84B7 /* NdbCompactionTests.swift */,
D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */,
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */,
@@ -4559,6 +4580,7 @@
5C78A7912E3036DA00CF177D /* Views */ = {
isa = PBXGroup;
children = (
D79C07452FA69DDB00BD4BFF /* PrivacySettingsView.swift */,
D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */,
D78778252F49478200DA73E4 /* StorageSettingsView.swift */,
4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */,
@@ -4646,6 +4668,7 @@
5C78A7992E303C1400CF177D /* Models */ = {
isa = PBXGroup;
children = (
D79C07492FA6A5AE00BD4BFF /* GlobalSettingsStore.swift */,
BA693073295D649800ADDB87 /* UserSettingsStore.swift */,
);
path = Models;
@@ -4948,6 +4971,7 @@
5C78A7B82E3047DE00CF177D /* Utilities */ = {
isa = PBXGroup;
children = (
D79C073F2FA579D600BD4BFF /* DamusSentry.swift */,
D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */,
D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */,
D7E5B2D22EA0187B00CF47AC /* StreamPipelineDiagnostics.swift */,
@@ -5523,6 +5547,7 @@
D7DB1FE32D5A9AC900CF06DA /* CryptoSwift */,
3ACF94372DA9A52F00971A4E /* FaviconFinder */,
D7E5B2DA2EA080BE00CF47AC /* Negentropy */,
5A958478DCE24724BA3B6460 /* Sentry */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -5740,6 +5765,7 @@
3ACF94362DA9A52F00971A4E /* XCRemoteSwiftPackageReference "FaviconFinder" */,
D7E5B2D92EA080BE00CF47AC /* XCRemoteSwiftPackageReference "negentropy-swift" */,
D74723EA2F15B0C3002DA12A /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */,
B1B8FE6C91C1401D965BD8DA /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -5847,6 +5873,7 @@
shellPath = /bin/sh;
shellScript = "git rev-parse --short HEAD > \"${SRCROOT}/build-git-hash.txt\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -5955,6 +5982,7 @@
4CA5588329F33F5B00DC6A45 /* StringCodable.swift in Sources */,
4C75EFB92804A2740006080F /* EventView.swift in Sources */,
4C15C7152A55DE7A00D0A0DB /* ReactionsSettingsView.swift in Sources */,
D79C074D2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
4C32B94C2A9AD44700DC3548 /* FbConstants.swift in Sources */,
4C8D00C829DF791C0036AF10 /* CompatibleAttribute.swift in Sources */,
4C687C272A6039500092C550 /* TestData.swift in Sources */,
@@ -6115,11 +6143,13 @@
4CC7AAF0297F11C700430951 /* SelectedEventView.swift in Sources */,
4CC7AAF8297F1CEE00430951 /* EventProfile.swift in Sources */,
5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */,
D79C07472FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */,
4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */,
64FBD06F296255C400D9D3B2 /* Theme.swift in Sources */,
D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */,
4C1A9A2329DDDB8100516EAC /* IconLabel.swift in Sources */,
4CA352AC2A76C07F003BB08B /* NewUnmutesNotify.swift in Sources */,
D79C07412FA579DE00BD4BFF /* DamusSentry.swift in Sources */,
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */,
4CE879522996B68900F758CC /* RelayType.swift in Sources */,
4CE8795B2996C47A00F758CC /* ZapsModel.swift in Sources */,
@@ -6464,6 +6494,7 @@
B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */,
D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */,
4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */,
D79C07442FA57BB700BD4BFF /* SensitiveDataScrubbingTests.swift in Sources */,
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */,
D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */,
4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */,
@@ -6558,6 +6589,7 @@
82D6FAB52CD99F7900C925F4 /* NativeObject.swift in Sources */,
82D6FAB62CD99F7900C925F4 /* String+extension.swift in Sources */,
3A515C552DF5371D002D3B34 /* TrustedNetworkButtonTipViewStyle.swift in Sources */,
D79C07422FA579DE00BD4BFF /* DamusSentry.swift in Sources */,
82D6FAB72CD99F7900C925F4 /* FlatBufferObject.swift in Sources */,
82D6FAB82CD99F7900C925F4 /* Enum.swift in Sources */,
82D6FAB92CD99F7900C925F4 /* builder.c in Sources */,
@@ -6754,6 +6786,7 @@
82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
D79C07482FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */,
82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
82D6FB6B2CD99F7900C925F4 /* StoreObserver.swift in Sources */,
@@ -6987,6 +7020,7 @@
82D6FC312CD99F7900C925F4 /* ProxyView.swift in Sources */,
82D6FC322CD99F7900C925F4 /* SelectedEventView.swift in Sources */,
5C8F97352EB46145009399B1 /* LiveStreamView.swift in Sources */,
D79C074A2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
82D6FC332CD99F7900C925F4 /* EventBody.swift in Sources */,
D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */,
82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */,
@@ -7334,6 +7368,7 @@
D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */,
D73E5ED82C6A97F4007EB227 /* DamusVideoPlayerView.swift in Sources */,
D73E5ED92C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */,
D79C07462FA69DDB00BD4BFF /* PrivacySettingsView.swift in Sources */,
D73E5EDA2C6A97F4007EB227 /* DamusVideoCoordinator.swift in Sources */,
D73E5EDC2C6A97F4007EB227 /* ReactionsSettingsView.swift in Sources */,
D73E5EDD2C6A97F4007EB227 /* NotificationSettingsView.swift in Sources */,
@@ -7560,6 +7595,7 @@
D703D7A92C670E5A00A400EA /* refmap.c in Sources */,
D73C7EDC2DE51699001F9392 /* OnboardingContentSettings.swift in Sources */,
5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */,
D79C07402FA579DE00BD4BFF /* DamusSentry.swift in Sources */,
D703D77B2C670BF000A400EA /* TableVerifier.swift in Sources */,
3ACF94442DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */,
D703D76D2C670B4500A400EA /* ZapDataModel.swift in Sources */,
@@ -7618,6 +7654,7 @@
D703D7892C670C8600A400EA /* DeepLPlan.swift in Sources */,
5CB645AA2EAC01430018BD91 /* DamusLabsExperiments.swift in Sources */,
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
D79C074C2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
@@ -7741,10 +7778,12 @@
D7CCFC162B05894300323D86 /* Pubkey.swift in Sources */,
D7EDED2C2B128CFA0018B19C /* DamusColors.swift in Sources */,
D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */,
D79C074E2FA9252600BD4BFF /* DamusSentry.swift in Sources */,
D798D2292B08686C00234419 /* ContentParsing.swift in Sources */,
D798D2242B0859C900234419 /* LocalizationUtil.swift in Sources */,
D7CE1B322B0BE6C3002EDAD4 /* NdbTxn.swift in Sources */,
D7CE1B372B0BE719002EDAD4 /* Verifier.swift in Sources */,
D79C074B2FA6A5AE00BD4BFF /* GlobalSettingsStore.swift in Sources */,
D74AAFC82B155C9D006CF0F4 /* InsertSort.swift in Sources */,
D723411A2B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */,
D7EDED292B1182060018B19C /* AttachMediaUtility.swift in Sources */,
@@ -8131,6 +8170,7 @@
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_PREVIEWS = YES;
@@ -8185,6 +8225,7 @@
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_PREVIEWS = YES;
@@ -8609,6 +8650,16 @@
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" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/twostraws/CodeScanner.git";
@@ -8708,6 +8759,11 @@
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;
productName = secp256k1;
};
5A958478DCE24724BA3B6460 /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = B1B8FE6C91C1401D965BD8DA /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
82D6FC802CD99FC500C925F4 /* secp256k1 */ = {
isa = XCSwiftPackageProductDependency;
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;
@@ -1,5 +1,5 @@
{
"originHash" : "c718c1e7dcc1a07671694b2d7d7311e11804fbbaf22f4b81e49523a3df816ad6",
"originHash" : "69116d3b516f6eb000e56fb88ccfca91c324778f3b97dfa2704e221da07b1ca2",
"pins" : [
{
"identity" : "codescanner",
@@ -88,6 +88,15 @@
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa/",
"state" : {
"revision" : "a49e7c2148ac9e38bd35ef4f13bc9d6ea3ff0b81",
"version" : "9.11.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
@@ -7,6 +7,7 @@
import Foundation
import os
import Negentropy
import Sentry
extension NostrNetworkManager {
@@ -272,6 +273,13 @@ extension NostrNetworkManager {
}
catch {
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")
continuation.finish()
@@ -308,6 +316,13 @@ extension NostrNetworkManager {
}
catch {
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 Network
import Negentropy
import Sentry
struct RelayHandler {
let sub_id: String
@@ -313,6 +314,12 @@ class RelayPool {
#if DEBUG
print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)")
#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)
}
else {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "negentropy_sync_multi_relay",
"ignore_unsupported": ignoreUnsupportedRelays
], key: "relay_pool")
}
throw error
}
}
@@ -7,6 +7,7 @@
import Foundation
import OSLog
import Sentry
/// Manages periodic snapshots of the main NostrDB database to a shared container location.
///
@@ -155,6 +156,12 @@ actor DatabaseSnapshotManager {
do {
try fileManager.createDirectory(atPath: tempSnapshotPath.path, withIntermediateDirectories: true)
} catch {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "create_temp_snapshot_directory",
"path": tempSnapshotPath.path
], key: "snapshot")
}
throw SnapshotError.directoryCreationFailed(error)
}
@@ -165,6 +172,12 @@ actor DatabaseSnapshotManager {
try fileManager.removeItem(atPath: tempSnapshotPath.path)
} catch {
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
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 {
@@ -307,6 +327,12 @@ actor DatabaseSnapshotManager {
do {
try fileManager.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
DamusSentry.captureSentryError(error) { scope in
scope.setContext(value: [
"operation": "create_parent_directory",
"path": parentDir
], key: "snapshot")
}
throw SnapshotError.directoryCreationFailed(error)
}
}
@@ -321,6 +347,14 @@ actor DatabaseSnapshotManager {
Log.debug("Moved snapshot from %{public}@ to %{public}@", for: .storage, tempPath, finalPath)
} 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)
}
}
+24 -1
View File
@@ -4,6 +4,7 @@
//
import SwiftUI
import Sentry
/// 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 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 {
Group {
@@ -92,6 +98,9 @@ struct CompactionView: View {
compactionState = .complete
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
compactionState = CompactionState.initialCompactingState(showsLargeDatabaseWarning: showsLargeDatabaseWarning)
@@ -116,7 +125,21 @@ struct CompactionView: View {
}
} catch {
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 Sentry
// Minimum threshold the hellthread pubkey tag count setting can go down to.
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)
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")
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)
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")
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)
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")
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 {
switch httpResponse.statusCode {
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
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")
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
+33 -8
View File
@@ -7,6 +7,7 @@
import Foundation
import StoreKit
import Sentry
class DamusPurple: StoreObserverDelegate {
let settings: UserSettingsStore
@@ -151,11 +152,19 @@ class DamusPurple: StoreObserverDelegate {
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Got user UUID from Damus Purple server", for: .damus_purple)
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")
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
case 200:
Log.info("Successfully fetched account UUID from Damus Purple server", for: .damus_purple)
default:
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) ?? "")
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)
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")
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:
Log.info("Sent transaction ID to Damus Purple server and activated successfully", for: .damus_purple)
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")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
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")
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
private let DELETE_KEYWORD = "DELETE"
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 searchUniverseTitle = NSLocalizedString("Search / Universe", comment: "Section header for search/universe settings")
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)
}
}
// Privacy
if showSettingsButton(title: privacyTitle){
NavigationLink(value: Route.PrivacySettings){
IconLabel(privacyTitle, img_name: "eye-off.fill", color:.blue)
}
}
// Appearance and filters
if showSettingsButton(title: appearanceTitle){
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 Sentry
/// 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 {
return
} 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 {
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
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
@@ -159,10 +177,27 @@ class CoinosDeterministicAccountClient {
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
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
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
@@ -203,10 +238,28 @@ class CoinosDeterministicAccountClient {
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
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
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
@@ -72,6 +72,25 @@ extension WalletConnect {
// 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
///
/// - Parameters:
+101 -7
View File
@@ -6,6 +6,7 @@
//
import Foundation
import Sentry
enum WalletConnectState {
case new(WalletConnectURL)
@@ -100,6 +101,13 @@ class WalletModel: ObservableObject {
/// - Parameter response: The NWC response received from the network
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
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)
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] {
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
}
@@ -156,7 +177,17 @@ class WalletModel: ObservableObject {
/// Fetches the balance amount from the network and returns the amount in sats
func fetchBalance() async throws -> Int64 {
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
}
@@ -169,7 +200,15 @@ class WalletModel: ObservableObject {
func request(_ request: WalletConnect.Request, timeout: Duration = .seconds(10)) async throws(WalletRequestError) -> WalletConnect.Response.Result {
guard let nostrNetwork else { throw .notConnectedToTheNostrNetwork }
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 = [
NostrFilter(
@@ -182,19 +221,60 @@ class WalletModel: ObservableObject {
await nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false)
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
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
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
}
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
}
@@ -247,3 +327,17 @@ class WalletModel: ObservableObject {
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 Sentry
enum ZappingEventType {
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 {
// 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
}
@@ -203,6 +212,11 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
let typ = ZappingEventType.failed(.bad_lnurl)
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
notify(.zapping(ev))
DamusSentry.captureSentryMessage("Failed to fetch lnurl payreq for zap") { scope in
scope.setContext(value: [
"operation": "zap_fetch_lnurl"
], key: "zaps")
}
return
}
@@ -211,6 +225,12 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
let typ = ZappingEventType.failed(.fetching_invoice)
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
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
}
@@ -248,6 +268,11 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
let typ = ZappingEventType.failed(.send_failed)
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
notify(.zapping(ev))
DamusSentry.captureSentryMessage("Failed to send NWC zap request") { scope in
scope.setContext(value: [
"operation": "nwc_pay_request",
], key: "zaps")
}
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 UserRelays(relays: [RelayURL])
case KeySettings(keypair: Keypair)
case PrivacySettings
case AppearanceSettings(settings: UserSettingsStore)
case NotificationSettings(settings: UserSettingsStore)
case ZapSettings(settings: UserSettingsStore)
@@ -86,6 +87,8 @@ enum Route: Hashable {
UserRelaysView(state: damusState, relays: relays)
case .KeySettings(let keypair):
KeySettingsView(keypair: keypair)
case .PrivacySettings:
PrivacySettingsView()
case .AppearanceSettings(let settings):
AppearanceSettingsView(damus_state: damusState, settings: settings)
case .NotificationSettings(let settings):
@@ -194,6 +197,8 @@ enum Route: Hashable {
case .KeySettings(let keypair):
hasher.combine("keySettings")
hasher.combine(keypair.pubkey)
case .PrivacySettings:
hasher.combine("privacySettings")
case .AppearanceSettings:
hasher.combine("appearanceSettings")
case .NotificationSettings:
+4
View File
@@ -6,6 +6,8 @@
//
import Kingfisher
import Sentry
import SwiftUI
import StoreKit
@@ -77,6 +79,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
var state: DamusState? = nil
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
DamusSentry.startIfEnabled()
UNUserNotificationCenter.current().delegate = self
SKPaymentQueue.default().add(StoreObserver.standard)
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": {}
}
}
}