krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

9092ab60f82152cc4ae38677c3af4a76013f4e54

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-25T05:05:53Z

refactor: extract workflow surface into DomainViewModel+Workflows (v5 step 4, 4/n)

Fourth slice of the DomainViewModel decomposition (off main; the audit/
monitoring/export stack has merged).

- Moves the workflow surface into DomainViewModel+Workflows.swift: workflow
  lookup (workflow(withID:)/workflowsContaining), the DomainWorkflow
  collaboration checks, the CRUD mutators (create/update/delete/add/remove/move),
  runWorkflow/rerunCurrentDomain, and refreshWorkflowList. Pure move. The
  TrackedDomain overloads of canEdit/canDelete/collaborationLabel stay on the
  main type (they're watchlist collaboration, not workflow).
- Promotes the shared helpers the moved methods reach to internal, all staying
  on the main type: persistWorkflows (also called by clearWorkflows),
  startBatchLookup (the batch primitive shared with manual/watchlist runs),
  normalizedDomain/normalizedDomains, loadWorkflows, and the activeWorkflowRunID/
  Name state. The extension carries its own fileprivate String.nilIfEmpty,
  matching the per-file pattern already used across the codebase.

DomainViewModel.swift: 4433 -> 4306 lines (4864 at the start of step 4).
App builds clean; unit suite 58/58. No project.pbxproj change.
 DomainDig/DomainViewModel+Workflows.swift | 144 ++++++++++++++++++++++++++++++
 DomainDig/DomainViewModel.swift           | 141 ++---------------------------
 2 files changed, 151 insertions(+), 134 deletions(-)

diff --git a/DomainDig/DomainViewModel+Workflows.swift b/DomainDig/DomainViewModel+Workflows.swift
new file mode 100644
index 0000000..d1e69b4
--- /dev/null
+++ b/DomainDig/DomainViewModel+Workflows.swift
@@ -0,0 +1,144 @@
+import Foundation
+import SwiftUI
+
+/// Workflow surface of `DomainViewModel`: looking up workflows, the
+/// collaboration permission checks for a workflow, the CRUD mutators, and
+/// running a workflow's domains as a batch.
+///
+/// `runWorkflow`/`rerunCurrentDomain` drive `startBatchLookup` (the shared batch
+/// primitive used by manual and watchlist runs too), which stays on the main
+/// type along with `persistWorkflows` and the `normalizedDomain(s)` helpers.
+extension DomainViewModel {
+    func workflow(withID id: UUID) -> DomainWorkflow? {
+        workflows.first(where: { $0.id == id })
+    }
+
+    func workflowsContaining(domain: String) -> [DomainWorkflow] {
+        let normalized = normalizedDomain(domain)
+        guard !normalized.isEmpty else { return [] }
+        return workflows.filter { workflow in
+            workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame })
+        }
+    }
+
+    func canEdit(_ workflow: DomainWorkflow) -> Bool {
+        workflow.collaboration?.canEdit ?? true
+    }
+
+    func canDelete(_ workflow: DomainWorkflow) -> Bool {
+        workflow.collaboration?.isOwner ?? true
+    }
+
+    func collaborationLabel(for workflow: DomainWorkflow) -> String? {
+        guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
+        return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
+    }
+
+    @discardableResult
+    func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
+        let normalizedDomains = normalizedDomains(domains)
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
+        guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
+            upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
+            return nil
+        }
+
+        let workflow = DomainWorkflow(
+            name: trimmedName,
+            domains: normalizedDomains,
+            createdAt: Date(),
+            updatedAt: Date(),
+            notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
+            collaboration: CollaborationMetadata(
+                scope: .privateDatabase,
+                ownership: .owner,
+                permission: .editable
+            )
+        )
+        workflows.insert(workflow, at: 0)
+        persistWorkflows()
+        return workflow
+    }
+
+    func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
+        guard canEdit(workflow) else { return }
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        let normalizedDomains = normalizedDomains(domains)
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return }
+
+        workflows[index].name = trimmedName
+        workflows[index].domains = normalizedDomains
+        workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func deleteWorkflow(_ workflow: DomainWorkflow) {
+        guard canDelete(workflow) else { return }
+        CloudSyncService.shared.recordWorkflowDeletion(workflow)
+        workflows.removeAll { $0.id == workflow.id }
+        if latestWorkflowRunSummary?.workflowID == workflow.id {
+            latestWorkflowRunSummary = nil
+        }
+        if activeWorkflowRunID == workflow.id {
+            activeWorkflowRunID = nil
+            activeWorkflowRunName = nil
+        }
+        persistWorkflows()
+    }
+
+    func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
+        guard canEdit(workflow) else { return }
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        let mergedDomains = normalizedDomains(workflows[index].domains + domains)
+        guard mergedDomains != workflows[index].domains else { return }
+        workflows[index].domains = mergedDomains
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
+        guard canEdit(workflow) else { return }
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        workflows[index].domains.remove(atOffsets: offsets)
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
+        guard canEdit(workflow) else { return }
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func runWorkflow(_ workflow: DomainWorkflow) {
+        guard !workflow.domains.isEmpty else { return }
+        guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
+            upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
+            return
+        }
+        startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
+    }
+
+    func rerunCurrentDomain(in workflow: DomainWorkflow) {
+        guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else {
+            return
+        }
+        runWorkflow(workflow)
+    }
+
+    func refreshWorkflowList() async {
+        workflows = Self.loadWorkflows()
+        await Task.yield()
+    }
+}
+
+private extension String {
+    var nilIfEmpty: String? {
+        isEmpty ? nil : self
+    }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 250d46d..e0f649b 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -275,8 +275,8 @@ final class DomainViewModel {
     private var lookupStartedAt: Date?
     private var activeBatchDomains: [String] = []
     private var lastBatchStartedAt: Date?
-    private var activeWorkflowRunID: UUID?
-    private var activeWorkflowRunName: String?
+    var activeWorkflowRunID: UUID?
+    var activeWorkflowRunName: String?
     private var historyPersistenceSuspended = false
     private var trackedDomainsPersistenceSuspended = false
     private var historyPersistenceDirty = false
@@ -1195,18 +1195,6 @@ final class DomainViewModel {
         }
     }
 
-    func workflow(withID id: UUID) -> DomainWorkflow? {
-        workflows.first(where: { $0.id == id })
-    }
-
-    func workflowsContaining(domain: String) -> [DomainWorkflow] {
-        let normalized = normalizedDomain(domain)
-        guard !normalized.isEmpty else { return [] }
-        return workflows.filter { workflow in
-            workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame })
-        }
-    }
-
     func canEdit(_ trackedDomain: TrackedDomain) -> Bool {
         trackedDomain.collaboration?.canEdit ?? true
     }
@@ -1220,121 +1208,6 @@ final class DomainViewModel {
         return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
     }
 
-    func canEdit(_ workflow: DomainWorkflow) -> Bool {
-        workflow.collaboration?.canEdit ?? true
-    }
-
-    func canDelete(_ workflow: DomainWorkflow) -> Bool {
-        workflow.collaboration?.isOwner ?? true
-    }
-
-    func collaborationLabel(for workflow: DomainWorkflow) -> String? {
-        guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
-        return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
-    }
-
-    @discardableResult
-    func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
-        let normalizedDomains = normalizedDomains(domains)
-        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
-        guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
-            upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
-            return nil
-        }
-
-        let workflow = DomainWorkflow(
-            name: trimmedName,
-            domains: normalizedDomains,
-            createdAt: Date(),
-            updatedAt: Date(),
-            notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
-            collaboration: CollaborationMetadata(
-                scope: .privateDatabase,
-                ownership: .owner,
-                permission: .editable
-            )
-        )
-        workflows.insert(workflow, at: 0)
-        persistWorkflows()
-        return workflow
-    }
-
-    func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
-        guard canEdit(workflow) else { return }
-        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
-        let normalizedDomains = normalizedDomains(domains)
-        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return }
-
-        workflows[index].name = trimmedName
-        workflows[index].domains = normalizedDomains
-        workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
-        workflows[index].updatedAt = Date()
-        persistWorkflows()
-    }
-
-    func deleteWorkflow(_ workflow: DomainWorkflow) {
-        guard canDelete(workflow) else { return }
-        CloudSyncService.shared.recordWorkflowDeletion(workflow)
-        workflows.removeAll { $0.id == workflow.id }
-        if latestWorkflowRunSummary?.workflowID == workflow.id {
-            latestWorkflowRunSummary = nil
-        }
-        if activeWorkflowRunID == workflow.id {
-            activeWorkflowRunID = nil
-            activeWorkflowRunName = nil
-        }
-        persistWorkflows()
-    }
-
-    func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
-        guard canEdit(workflow) else { return }
-        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
-        let mergedDomains = normalizedDomains(workflows[index].domains + domains)
-        guard mergedDomains != workflows[index].domains else { return }
-        workflows[index].domains = mergedDomains
-        workflows[index].updatedAt = Date()
-        persistWorkflows()
-    }
-
-    func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
-        guard canEdit(workflow) else { return }
-        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
-        workflows[index].domains.remove(atOffsets: offsets)
-        workflows[index].updatedAt = Date()
-        persistWorkflows()
-    }
-
-    func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
-        guard canEdit(workflow) else { return }
-        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
-        workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
-        workflows[index].updatedAt = Date()
-        persistWorkflows()
-    }
-
-    func runWorkflow(_ workflow: DomainWorkflow) {
-        guard !workflow.domains.isEmpty else { return }
-        guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
-            upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
-            return
-        }
-        startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
-    }
-
-    func rerunCurrentDomain(in workflow: DomainWorkflow) {
-        guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else {
-            return
-        }
-        runWorkflow(workflow)
-    }
-
-    func refreshWorkflowList() async {
-        workflows = Self.loadWorkflows()
-        await Task.yield()
-    }
-
     func runCustomPortScan(ports: [UInt16]) async {
         guard !searchedDomain.isEmpty else {
             customPortScanError = "Run a domain lookup first"
@@ -2361,7 +2234,7 @@ final class DomainViewModel {
         trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
     }
 
-    private func normalizedDomain(_ domain: String) -> String {
+    func normalizedDomain(_ domain: String) -> String {
         domain
             .trimmingCharacters(in: .whitespacesAndNewlines)
             .replacingOccurrences(of: "https://", with: "")
@@ -2438,7 +2311,7 @@ final class DomainViewModel {
         return lookupID
     }
 
-    private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
+    func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
         guard !domains.isEmpty else { return }
         guard !batchLookupRunning else { return }
 
@@ -3516,13 +3389,13 @@ final class DomainViewModel {
         return DomainDataPortabilityService.loadTrackedDomains()
     }
 
-    private func persistWorkflows() {
+    func persistWorkflows() {
         DomainDataPortabilityService.saveWorkflows(workflows)
         CloudSyncService.shared.scheduleSyncIfNeeded()
         refreshDataLifecycleSummary()
     }
 
-    private static func loadWorkflows() -> [DomainWorkflow] {
+    static func loadWorkflows() -> [DomainWorkflow] {
         DataMigrationService.migrateIfNeeded()
         return DomainDataPortabilityService.loadWorkflows()
     }
@@ -3535,7 +3408,7 @@ final class DomainViewModel {
         }
     }
 
-    private func normalizedDomains(_ domains: [String]) -> [String] {
+    func normalizedDomains(_ domains: [String]) -> [String] {
         var seen = Set<String>()
         return domains
             .map(normalizedDomain)