summaryrefslogtreecommitdiff
path: root/DomainDig-4.1.0.patch
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig-4.1.0.patch')
-rw-r--r--DomainDig-4.1.0.patch413
1 files changed, 413 insertions, 0 deletions
diff --git a/DomainDig-4.1.0.patch b/DomainDig-4.1.0.patch
new file mode 100644
index 0000000..3c90b2c
--- /dev/null
+++ b/DomainDig-4.1.0.patch
@@ -0,0 +1,413 @@
+diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
+index c641a56..2de9125 100644
+--- a/DomainDig.xcodeproj/project.pbxproj
++++ b/DomainDig.xcodeproj/project.pbxproj
+@@ -366,7 +366,7 @@
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+- CURRENT_PROJECT_VERSION = 32;
++ CURRENT_PROJECT_VERSION = 33;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+@@ -383,7 +383,7 @@
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+- MARKETING_VERSION = 4.0.0;
++ MARKETING_VERSION = 4.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+@@ -403,7 +403,7 @@
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+- CURRENT_PROJECT_VERSION = 32;
++ CURRENT_PROJECT_VERSION = 33;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+@@ -420,7 +420,7 @@
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+- MARKETING_VERSION = 4.0.0;
++ MARKETING_VERSION = 4.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift
+index 1cf41d9..2cb4cfe 100644
+--- a/DomainDig/CloudSyncService.swift
++++ b/DomainDig/CloudSyncService.swift
+@@ -321,6 +321,20 @@ final class CloudSyncService {
+ }
+ }
+
++ func resetLocalStateAfterWipe() {
++ scheduledSyncTask?.cancel()
++ scheduledSyncTask = nil
++ syncTask?.cancel()
++ syncTask = nil
++
++ let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled)
++ isEnabled = syncEnabled
++ status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled)
++ lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date
++ lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage)
++ detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account."
++ }
++
+ func acceptShare(metadata: CKShare.Metadata) async throws {
+ guard let container = cloudKitContainer() else {
+ throw CloudSyncRuntimeError.missingEntitlement
+diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
+index c9f40d6..c5c8e50 100644
+--- a/DomainDig/ContentView.swift
++++ b/DomainDig/ContentView.swift
+@@ -261,6 +261,9 @@ struct ContentView: View {
+ availableDomains: viewModel.batchResults.map(\.domain)
+ )
+ }
++ .sheet(item: manualBatchSummaryBinding) { summary in
++ BatchSweepSummaryView(viewModel: viewModel, summary: summary)
++ }
+ .sheet(isPresented: $showingTimeline) {
+ NavigationStack {
+ TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
+@@ -268,6 +271,19 @@ struct ContentView: View {
+ }
+ }
+
++ private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
++ Binding(
++ get: {
++ guard let summary = viewModel.latestBatchSweepSummary,
++ summary.source == .manual else {
++ return nil
++ }
++ return summary
++ },
++ set: { viewModel.latestBatchSweepSummary = $0 }
++ )
++ }
++
+ private var inputSection: some View {
+ VStack(spacing: appDensity.metrics.cardSpacing + 2) {
+ Picker("Mode", selection: $inputMode) {
+@@ -3257,6 +3273,10 @@ private struct DataManagementSettingsView: View {
+ @State private var showClearCacheConfirmation = false
+ @State private var showClearWorkflowsConfirmation = false
+ @State private var showClearTrackedDomainsConfirmation = false
++ @State private var showDeleteAllConfirmation = false
++ @State private var deleteAllErrorMessage: String?
++ @State private var deleteAllSuccessMessage: String?
++ @State private var isDeletingAllData = false
+
+ var body: some View {
+ Form {
+@@ -3277,7 +3297,28 @@ private struct DataManagementSettingsView: View {
+ showClearTrackedDomainsConfirmation = true
+ }
+ }
++
++ Section {
++ Button(role: .destructive) {
++ showDeleteAllConfirmation = true
++ } label: {
++ HStack {
++ Text("Delete All Data")
++ Spacer()
++ if isDeletingAllData {
++ ProgressView()
++ .controlSize(.small)
++ }
++ }
++ }
++ .disabled(isDeletingAllData)
++ } header: {
++ Text("Danger Zone")
++ } footer: {
++ Text("Permanently removes all local DomainDig data from this device.")
++ }
+ }
++ .disabled(isDeletingAllData)
+ .navigationTitle("Data Management")
+ .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
+ Button("Clear", role: .destructive) {
+@@ -3311,6 +3352,57 @@ private struct DataManagementSettingsView: View {
+ } message: {
+ Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
+ }
++ .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
++ Button("Cancel", role: .cancel) {}
++ Button("Delete All Data", role: .destructive) {
++ deleteAllData()
++ }
++ } message: {
++ Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.")
++ }
++ .alert("Delete Failed", isPresented: Binding(
++ get: { deleteAllErrorMessage != nil },
++ set: { if !$0 { deleteAllErrorMessage = nil } }
++ )) {
++ Button("OK", role: .cancel) {}
++ } message: {
++ Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
++ }
++ .safeAreaInset(edge: .bottom) {
++ if let deleteAllSuccessMessage {
++ Text(deleteAllSuccessMessage)
++ .font(.footnote.weight(.medium))
++ .foregroundStyle(.secondary)
++ .padding(.horizontal, 14)
++ .padding(.vertical, 10)
++ .background(.thinMaterial, in: Capsule())
++ .padding(.bottom, 8)
++ .transition(.move(edge: .bottom).combined(with: .opacity))
++ }
++ }
++ }
++
++ private func deleteAllData() {
++ guard !isDeletingAllData else { return }
++
++ isDeletingAllData = true
++ deleteAllErrorMessage = nil
++ deleteAllSuccessMessage = nil
++
++ Task {
++ do {
++ try await DataResetService.wipeAllLocalData(viewModel: viewModel)
++ deleteAllSuccessMessage = "All local data removed."
++ try? await Task.sleep(for: .seconds(2))
++ if deleteAllSuccessMessage == "All local data removed." {
++ deleteAllSuccessMessage = nil
++ }
++ } catch {
++ deleteAllErrorMessage = error.localizedDescription
++ }
++
++ isDeletingAllData = false
++ }
+ }
+ }
+
+diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
+index b65c6df..1d75221 100644
+--- a/DomainDig/DomainViewModel.swift
++++ b/DomainDig/DomainViewModel.swift
+@@ -953,6 +953,46 @@ final class DomainViewModel {
+ refreshDataLifecycleSummary()
+ }
+
++ func applyLocalDataReset() async {
++ domain = ""
++ bulkInput = ""
++ reset()
++
++ recentSearches = []
++ savedDomains = []
++ trackedDomains = []
++ history = []
++ workflows = []
++ historySearchText = ""
++ historyDateFilter = .all
++ historyChangeFilter = .all
++ historySortOption = .newest
++ timelineGrouping = .relativeDay
++ timelineDomainFilter = ""
++ watchlistSearchText = ""
++ watchlistFilter = .all
++ watchlistSortOption = .pinned
++ dashboardSearchText = ""
++ dashboardFilter = .all
++ monitoringSettings = MonitoringSettings()
++ monitoringLogs = []
++ notificationsAuthorized = false
++ monitoringRunInProgress = false
++ monitoringStatusMessage = nil
++ portabilityStatusMessage = "All local data removed."
++ upgradePrompt = nil
++ isPaywallPresented = false
++ selectedSnapshotIDs.removeAll()
++ activeDomainDiff = nil
++ activeDiffChangeIndex = 0
++ latestBatchSweepSummary = nil
++ latestWorkflowRunSummary = nil
++ historyAutoPruneOption = Self.loadHistoryAutoPruneOption()
++ refreshDataLifecycleSummary()
++ await refreshUsageCredits()
++ await refreshMonitoringAuthorizationStatus()
++ }
++
+ func refreshMonitoringAuthorizationStatus() async {
+ let settings = await UNUserNotificationCenter.current().notificationSettings()
+ monitoringNotificationStatus = settings.authorizationStatus
+diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift
+index 838cfc2..dba8034 100644
+--- a/DomainDig/IntegrationService.swift
++++ b/DomainDig/IntegrationService.swift
+@@ -177,6 +177,28 @@ final class IntegrationService {
+ scheduleProcessing(force: true)
+ }
+
++ func localSecretReferences() -> [String] {
++ targets.compactMap { target in
++ switch target.configuration {
++ case .webhook(let configuration):
++ configuration.credentialReference
++ case .slack(let configuration):
++ configuration.credentialReference
++ case .email(let configuration):
++ configuration.credentialReference
++ }
++ }
++ }
++
++ func resetAfterLocalWipe() {
++ processingTask?.cancel()
++ processingTask = nil
++ targets = []
++ deliveryRecords = []
++ queue = []
++ statusMessage = nil
++ }
++
+ private func scheduleProcessing(force: Bool = false) {
+ if force {
+ processingTask?.cancel()
+diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
+index 93beca5..eef0567 100644
+--- a/DomainDig/LocalNotificationService.swift
++++ b/DomainDig/LocalNotificationService.swift
+@@ -117,6 +117,12 @@ final class LocalNotificationService {
+ )
+ }
+
++ func clearAllNotifications() async {
++ let center = UNUserNotificationCenter.current()
++ center.removeAllPendingNotificationRequests()
++ center.removeAllDeliveredNotifications()
++ }
++
+ private func schedule(
+ identifier: String,
+ title: String,
+diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift
+index af34a33..dbc31ba 100644
+--- a/DomainDig/PurchaseService.swift
++++ b/DomainDig/PurchaseService.swift
+@@ -196,6 +196,14 @@ final class PurchaseService {
+ errorMessage = nil
+ }
+
++ func resetCachedStateAfterLocalWipe() {
++ currentTier = Self.cachedTier
++ activeProductID = Self.cachedEntitlement?.activeProductID
++ statusMessage = nil
++ errorMessage = nil
++ applyDebugOverrideIfNeeded()
++ }
++
+ private func apply(transaction: Transaction) {
+ guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
+ return
+diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
+index 06ae96b..11273c7 100644
+--- a/DomainDig/WorkflowsView.swift
++++ b/DomainDig/WorkflowsView.swift
+@@ -100,7 +100,8 @@ struct WorkflowsView: View {
+ title: "No Workflows Yet",
+ message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
+ suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
+- systemImage: "square.stack.3d.down.right"
++ systemImage: "square.stack.3d.down.right",
++ showsCardBackground: false
+ )
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+diff --git a/DomainDig/DataResetService.swift b/DomainDig/DataResetService.swift
+new file mode 100644
+index 0000000..e993eba
+--- /dev/null
++++ b/DomainDig/DataResetService.swift
+@@ -0,0 +1,76 @@
++import Foundation
++import Security
++
++enum DataResetService {
++ enum ResetError: LocalizedError {
++ case missingBundleIdentifier
++
++ var errorDescription: String? {
++ switch self {
++ case .missingBundleIdentifier:
++ "DomainDig could not determine its local storage identifier."
++ }
++ }
++ }
++
++ static func wipeAllLocalData(viewModel: DomainViewModel) async throws {
++ let secretReferences = await MainActor.run {
++ IntegrationService.shared.localSecretReferences()
++ }
++
++ try await Task.detached(priority: .userInitiated) {
++ try performPersistentWipe(secretReferences: secretReferences)
++ }.value
++
++ await LookupRuntime.shared.clearCache()
++ await LocalNotificationService.shared.clearAllNotifications()
++ await UsageCreditService.shared.resetForCurrentVersion()
++
++ await MainActor.run {
++ IntegrationService.shared.resetAfterLocalWipe()
++ CloudSyncService.shared.resetLocalStateAfterWipe()
++ PurchaseService.shared.resetCachedStateAfterLocalWipe()
++ _ = DomainMonitoringScheduler.shared.syncSchedule()
++ }
++
++ await viewModel.applyLocalDataReset()
++ }
++
++ private nonisolated static func performPersistentWipe(secretReferences: [String]) throws {
++ guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
++ throw ResetError.missingBundleIdentifier
++ }
++
++ for reference in secretReferences {
++ deleteIntegrationSecret(reference: reference)
++ }
++
++ try removeTemporaryFiles()
++
++ let defaults = UserDefaults.standard
++ defaults.removePersistentDomain(forName: bundleIdentifier)
++ defaults.synchronize()
++ }
++
++ private nonisolated static func removeTemporaryFiles() throws {
++ let tempDirectory = FileManager.default.temporaryDirectory
++ let urls = try FileManager.default.contentsOfDirectory(
++ at: tempDirectory,
++ includingPropertiesForKeys: nil,
++ options: [.skipsHiddenFiles]
++ )
++
++ for url in urls {
++ try? FileManager.default.removeItem(at: url)
++ }
++ }
++
++ private nonisolated static func deleteIntegrationSecret(reference: String) {
++ let query: [String: Any] = [
++ kSecClass as String: kSecClassGenericPassword,
++ kSecAttrAccount as String: reference
++ ]
++
++ SecItemDelete(query as CFDictionary)
++ }
++}