Commit 9092ab60f8
Unsigned
Layout: unified · split
DomainDig/DomainViewModel+Workflows.swift added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | import Foundation | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Workflow surface of `DomainViewModel`: looking up workflows, the | |
| 5 | /// collaboration permission checks for a workflow, the CRUD mutators, and | |
| 6 | /// running a workflow's domains as a batch. | |
| 7 | /// | |
| 8 | /// `runWorkflow`/`rerunCurrentDomain` drive `startBatchLookup` (the shared batch | |
| 9 | /// primitive used by manual and watchlist runs too), which stays on the main | |
| 10 | /// type along with `persistWorkflows` and the `normalizedDomain(s)` helpers. | |
| 11 | extension DomainViewModel { | |
| 12 | func workflow(withID id: UUID) -> DomainWorkflow? { | |
| 13 | workflows.first(where: { $0.id == id }) | |
| 14 | } | |
| 15 | ||
| 16 | func workflowsContaining(domain: String) -> [DomainWorkflow] { | |
| 17 | let normalized = normalizedDomain(domain) | |
| 18 | guard !normalized.isEmpty else { return [] } | |
| 19 | return workflows.filter { workflow in | |
| 20 | workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | func canEdit(_ workflow: DomainWorkflow) -> Bool { | |
| 25 | workflow.collaboration?.canEdit ?? true | |
| 26 | } | |
| 27 | ||
| 28 | func canDelete(_ workflow: DomainWorkflow) -> Bool { | |
| 29 | workflow.collaboration?.isOwner ?? true | |
| 30 | } | |
| 31 | ||
| 32 | func collaborationLabel(for workflow: DomainWorkflow) -> String? { | |
| 33 | guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil } | |
| 34 | return "\(collaboration.ownership.title) • \(collaboration.permission.title)" | |
| 35 | } | |
| 36 | ||
| 37 | @discardableResult | |
| 38 | func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? { | |
| 39 | let normalizedDomains = normalizedDomains(domains) | |
| 40 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 41 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } | |
| 42 | guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { | |
| 43 | upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count) | |
| 44 | return nil | |
| 45 | } | |
| 46 | ||
| 47 | let workflow = DomainWorkflow( | |
| 48 | name: trimmedName, | |
| 49 | domains: normalizedDomains, | |
| 50 | createdAt: Date(), | |
| 51 | updatedAt: Date(), | |
| 52 | notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, | |
| 53 | collaboration: CollaborationMetadata( | |
| 54 | scope: .privateDatabase, | |
| 55 | ownership: .owner, | |
| 56 | permission: .editable | |
| 57 | ) | |
| 58 | ) | |
| 59 | workflows.insert(workflow, at: 0) | |
| 60 | persistWorkflows() | |
| 61 | return workflow | |
| 62 | } | |
| 63 | ||
| 64 | func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) { | |
| 65 | guard canEdit(workflow) else { return } | |
| 66 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 67 | let normalizedDomains = normalizedDomains(domains) | |
| 68 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 69 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return } | |
| 70 | ||
| 71 | workflows[index].name = trimmedName | |
| 72 | workflows[index].domains = normalizedDomains | |
| 73 | workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty | |
| 74 | workflows[index].updatedAt = Date() | |
| 75 | persistWorkflows() | |
| 76 | } | |
| 77 | ||
| 78 | func deleteWorkflow(_ workflow: DomainWorkflow) { | |
| 79 | guard canDelete(workflow) else { return } | |
| 80 | CloudSyncService.shared.recordWorkflowDeletion(workflow) | |
| 81 | workflows.removeAll { $0.id == workflow.id } | |
| 82 | if latestWorkflowRunSummary?.workflowID == workflow.id { | |
| 83 | latestWorkflowRunSummary = nil | |
| 84 | } | |
| 85 | if activeWorkflowRunID == workflow.id { | |
| 86 | activeWorkflowRunID = nil | |
| 87 | activeWorkflowRunName = nil | |
| 88 | } | |
| 89 | persistWorkflows() | |
| 90 | } | |
| 91 | ||
| 92 | func addDomains(_ domains: [String], to workflow: DomainWorkflow) { | |
| 93 | guard canEdit(workflow) else { return } | |
| 94 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 95 | let mergedDomains = normalizedDomains(workflows[index].domains + domains) | |
| 96 | guard mergedDomains != workflows[index].domains else { return } | |
| 97 | workflows[index].domains = mergedDomains | |
| 98 | workflows[index].updatedAt = Date() | |
| 99 | persistWorkflows() | |
| 100 | } | |
| 101 | ||
| 102 | func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) { | |
| 103 | guard canEdit(workflow) else { return } | |
| 104 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 105 | workflows[index].domains.remove(atOffsets: offsets) | |
| 106 | workflows[index].updatedAt = Date() | |
| 107 | persistWorkflows() | |
| 108 | } | |
| 109 | ||
| 110 | func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) { | |
| 111 | guard canEdit(workflow) else { return } | |
| 112 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 113 | workflows[index].domains.move(fromOffsets: offsets, toOffset: destination) | |
| 114 | workflows[index].updatedAt = Date() | |
| 115 | persistWorkflows() | |
| 116 | } | |
| 117 | ||
| 118 | func runWorkflow(_ workflow: DomainWorkflow) { | |
| 119 | guard !workflow.domains.isEmpty else { return } | |
| 120 | guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { | |
| 121 | upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count) | |
| 122 | return | |
| 123 | } | |
| 124 | startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) | |
| 125 | } | |
| 126 | ||
| 127 | func rerunCurrentDomain(in workflow: DomainWorkflow) { | |
| 128 | guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else { | |
| 129 | return | |
| 130 | } | |
| 131 | runWorkflow(workflow) | |
| 132 | } | |
| 133 | ||
| 134 | func refreshWorkflowList() async { | |
| 135 | workflows = Self.loadWorkflows() | |
| 136 | await Task.yield() | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | private extension String { | |
| 141 | var nilIfEmpty: String? { | |
| 142 | isEmpty ? nil : self | |
| 143 | } | |
| 144 | } | |
DomainDig/DomainViewModel.swift +7 −134
| @@ -275,8 +275,8 @@ final class DomainViewModel { | ||
| 275 | 275 | private var lookupStartedAt: Date? |
| 276 | 276 | private var activeBatchDomains: [String] = [] |
| 277 | 277 | private var lastBatchStartedAt: Date? |
| 278 | private var activeWorkflowRunID: UUID? | |
| 279 | private var activeWorkflowRunName: String? | |
| 278 | var activeWorkflowRunID: UUID? | |
| 279 | var activeWorkflowRunName: String? | |
| 280 | 280 | private var historyPersistenceSuspended = false |
| 281 | 281 | private var trackedDomainsPersistenceSuspended = false |
| 282 | 282 | private var historyPersistenceDirty = false |
| @@ -1195,18 +1195,6 @@ final class DomainViewModel { | ||
| 1195 | 1195 | } |
| 1196 | 1196 | } |
| 1197 | 1197 | |
| 1198 | func workflow(withID id: UUID) -> DomainWorkflow? { | |
| 1199 | workflows.first(where: { $0.id == id }) | |
| 1200 | } | |
| 1201 | ||
| 1202 | func workflowsContaining(domain: String) -> [DomainWorkflow] { | |
| 1203 | let normalized = normalizedDomain(domain) | |
| 1204 | guard !normalized.isEmpty else { return [] } | |
| 1205 | return workflows.filter { workflow in | |
| 1206 | workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) | |
| 1207 | } | |
| 1208 | } | |
| 1209 | ||
| 1210 | 1198 | func canEdit(_ trackedDomain: TrackedDomain) -> Bool { |
| 1211 | 1199 | trackedDomain.collaboration?.canEdit ?? true |
| 1212 | 1200 | } |
| @@ -1220,121 +1208,6 @@ final class DomainViewModel { | ||
| 1220 | 1208 | return "\(collaboration.ownership.title) • \(collaboration.permission.title)" |
| 1221 | 1209 | } |
| 1222 | 1210 | |
| 1223 | func canEdit(_ workflow: DomainWorkflow) -> Bool { | |
| 1224 | workflow.collaboration?.canEdit ?? true | |
| 1225 | } | |
| 1226 | ||
| 1227 | func canDelete(_ workflow: DomainWorkflow) -> Bool { | |
| 1228 | workflow.collaboration?.isOwner ?? true | |
| 1229 | } | |
| 1230 | ||
| 1231 | func collaborationLabel(for workflow: DomainWorkflow) -> String? { | |
| 1232 | guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil } | |
| 1233 | return "\(collaboration.ownership.title) • \(collaboration.permission.title)" | |
| 1234 | } | |
| 1235 | ||
| 1236 | @discardableResult | |
| 1237 | func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? { | |
| 1238 | let normalizedDomains = normalizedDomains(domains) | |
| 1239 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 1240 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } | |
| 1241 | guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { | |
| 1242 | upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count) | |
| 1243 | return nil | |
| 1244 | } | |
| 1245 | ||
| 1246 | let workflow = DomainWorkflow( | |
| 1247 | name: trimmedName, | |
| 1248 | domains: normalizedDomains, | |
| 1249 | createdAt: Date(), | |
| 1250 | updatedAt: Date(), | |
| 1251 | notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, | |
| 1252 | collaboration: CollaborationMetadata( | |
| 1253 | scope: .privateDatabase, | |
| 1254 | ownership: .owner, | |
| 1255 | permission: .editable | |
| 1256 | ) | |
| 1257 | ) | |
| 1258 | workflows.insert(workflow, at: 0) | |
| 1259 | persistWorkflows() | |
| 1260 | return workflow | |
| 1261 | } | |
| 1262 | ||
| 1263 | func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) { | |
| 1264 | guard canEdit(workflow) else { return } | |
| 1265 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 1266 | let normalizedDomains = normalizedDomains(domains) | |
| 1267 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 1268 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return } | |
| 1269 | ||
| 1270 | workflows[index].name = trimmedName | |
| 1271 | workflows[index].domains = normalizedDomains | |
| 1272 | workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty | |
| 1273 | workflows[index].updatedAt = Date() | |
| 1274 | persistWorkflows() | |
| 1275 | } | |
| 1276 | ||
| 1277 | func deleteWorkflow(_ workflow: DomainWorkflow) { | |
| 1278 | guard canDelete(workflow) else { return } | |
| 1279 | CloudSyncService.shared.recordWorkflowDeletion(workflow) | |
| 1280 | workflows.removeAll { $0.id == workflow.id } | |
| 1281 | if latestWorkflowRunSummary?.workflowID == workflow.id { | |
| 1282 | latestWorkflowRunSummary = nil | |
| 1283 | } | |
| 1284 | if activeWorkflowRunID == workflow.id { | |
| 1285 | activeWorkflowRunID = nil | |
| 1286 | activeWorkflowRunName = nil | |
| 1287 | } | |
| 1288 | persistWorkflows() | |
| 1289 | } | |
| 1290 | ||
| 1291 | func addDomains(_ domains: [String], to workflow: DomainWorkflow) { | |
| 1292 | guard canEdit(workflow) else { return } | |
| 1293 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 1294 | let mergedDomains = normalizedDomains(workflows[index].domains + domains) | |
| 1295 | guard mergedDomains != workflows[index].domains else { return } | |
| 1296 | workflows[index].domains = mergedDomains | |
| 1297 | workflows[index].updatedAt = Date() | |
| 1298 | persistWorkflows() | |
| 1299 | } | |
| 1300 | ||
| 1301 | func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) { | |
| 1302 | guard canEdit(workflow) else { return } | |
| 1303 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 1304 | workflows[index].domains.remove(atOffsets: offsets) | |
| 1305 | workflows[index].updatedAt = Date() | |
| 1306 | persistWorkflows() | |
| 1307 | } | |
| 1308 | ||
| 1309 | func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) { | |
| 1310 | guard canEdit(workflow) else { return } | |
| 1311 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | |
| 1312 | workflows[index].domains.move(fromOffsets: offsets, toOffset: destination) | |
| 1313 | workflows[index].updatedAt = Date() | |
| 1314 | persistWorkflows() | |
| 1315 | } | |
| 1316 | ||
| 1317 | func runWorkflow(_ workflow: DomainWorkflow) { | |
| 1318 | guard !workflow.domains.isEmpty else { return } | |
| 1319 | guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { | |
| 1320 | upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count) | |
| 1321 | return | |
| 1322 | } | |
| 1323 | startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) | |
| 1324 | } | |
| 1325 | ||
| 1326 | func rerunCurrentDomain(in workflow: DomainWorkflow) { | |
| 1327 | guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else { | |
| 1328 | return | |
| 1329 | } | |
| 1330 | runWorkflow(workflow) | |
| 1331 | } | |
| 1332 | ||
| 1333 | func refreshWorkflowList() async { | |
| 1334 | workflows = Self.loadWorkflows() | |
| 1335 | await Task.yield() | |
| 1336 | } | |
| 1337 | ||
| 1338 | 1211 | func runCustomPortScan(ports: [UInt16]) async { |
| 1339 | 1212 | guard !searchedDomain.isEmpty else { |
| 1340 | 1213 | customPortScanError = "Run a domain lookup first" |
| @@ -2361,7 +2234,7 @@ final class DomainViewModel { | ||
| 2361 | 2234 | trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } |
| 2362 | 2235 | } |
| 2363 | 2236 | |
| 2364 | private func normalizedDomain(_ domain: String) -> String { | |
| 2237 | func normalizedDomain(_ domain: String) -> String { | |
| 2365 | 2238 | domain |
| 2366 | 2239 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| 2367 | 2240 | .replacingOccurrences(of: "https://", with: "") |
| @@ -2438,7 +2311,7 @@ final class DomainViewModel { | ||
| 2438 | 2311 | return lookupID |
| 2439 | 2312 | } |
| 2440 | 2313 | |
| 2441 | private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) { | |
| 2314 | func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) { | |
| 2442 | 2315 | guard !domains.isEmpty else { return } |
| 2443 | 2316 | guard !batchLookupRunning else { return } |
| 2444 | 2317 | |
| @@ -3516,13 +3389,13 @@ final class DomainViewModel { | ||
| 3516 | 3389 | return DomainDataPortabilityService.loadTrackedDomains() |
| 3517 | 3390 | } |
| 3518 | 3391 | |
| 3519 | private func persistWorkflows() { | |
| 3392 | func persistWorkflows() { | |
| 3520 | 3393 | DomainDataPortabilityService.saveWorkflows(workflows) |
| 3521 | 3394 | CloudSyncService.shared.scheduleSyncIfNeeded() |
| 3522 | 3395 | refreshDataLifecycleSummary() |
| 3523 | 3396 | } |
| 3524 | 3397 | |
| 3525 | private static func loadWorkflows() -> [DomainWorkflow] { | |
| 3398 | static func loadWorkflows() -> [DomainWorkflow] { | |
| 3526 | 3399 | DataMigrationService.migrateIfNeeded() |
| 3527 | 3400 | return DomainDataPortabilityService.loadWorkflows() |
| 3528 | 3401 | } |
| @@ -3535,7 +3408,7 @@ final class DomainViewModel { | ||
| 3535 | 3408 | } |
| 3536 | 3409 | } |
| 3537 | 3410 | |
| 3538 | private func normalizedDomains(_ domains: [String]) -> [String] { | |
| 3411 | func normalizedDomains(_ domains: [String]) -> [String] { | |
| 3539 | 3412 | var seen = Set<String>() |
| 3540 | 3413 | return domains |
| 3541 | 3414 | .map(normalizedDomain) |