krz/domain-dig

an ios app for DNS & SSL analysis

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

main: DomainDig/SweepActivityController.swift · raw

 1import ActivityKit
 2import Foundation
 3
 4/// Starts, updates, and ends the sweep Live Activity around a batch run.
 5///
 6/// Holds the activity's `id` (a Sendable `String`) rather than the
 7/// `Activity` object itself. `Activity` is not Sendable, and sending the
 8/// stored reference into the fire-and-forget update task while `self` still
 9/// held it was a Swift 6 region-isolation violation (issue #27). Each task
10/// re-resolves the activity via `Activity.activities`, ActivityKit's
11/// sanctioned lookup, so nothing non-Sendable crosses an isolation boundary.
12@MainActor
13final class SweepActivityController {
14    static let shared = SweepActivityController()
15
16    private var activityID: String?
17
18    private init() { /* Singleton; use the shared instance. */ }
19
20    func begin(title: String, total: Int) {
21        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
22        // A previous activity that never ended (e.g. app killed mid-sweep)
23        // would otherwise linger; replace it.
24        end(changed: 0, warnings: 0, immediately: true)
25
26        let state = SweepActivityAttributes.ContentState(
27            completed: 0,
28            total: total,
29            currentDomain: nil,
30            changed: 0,
31            warnings: 0
32        )
33        let activity = try? Activity.request(
34            attributes: SweepActivityAttributes(title: title, startedAt: Date()),
35            content: ActivityContent(state: state, staleDate: nil)
36        )
37        activityID = activity?.id
38    }
39
40    func update(completed: Int, total: Int, currentDomain: String?) {
41        guard let activityID else { return }
42        let state = SweepActivityAttributes.ContentState(
43            completed: completed,
44            total: total,
45            currentDomain: currentDomain,
46            changed: 0,
47            warnings: 0
48        )
49        Task {
50            guard let activity = Self.activity(withID: activityID) else { return }
51            await activity.update(ActivityContent(state: state, staleDate: nil))
52        }
53    }
54
55    func end(changed: Int, warnings: Int, immediately: Bool = false) {
56        guard let activityID else { return }
57        self.activityID = nil
58        Task {
59            guard let activity = Self.activity(withID: activityID) else { return }
60            let total = activity.content.state.total
61            let state = SweepActivityAttributes.ContentState(
62                completed: total,
63                total: total,
64                currentDomain: nil,
65                changed: changed,
66                warnings: warnings
67            )
68            await activity.end(
69                ActivityContent(state: state, staleDate: nil),
70                dismissalPolicy: immediately ? .immediate : .after(Date().addingTimeInterval(60))
71            )
72        }
73    }
74
75    private nonisolated static func activity(withID id: String) -> Activity<SweepActivityAttributes>? {
76        Activity<SweepActivityAttributes>.activities.first { $0.id == id }
77    }
78}