summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj12
-rw-r--r--DomainDig/DomainDigIntents.swift20
-rw-r--r--DomainDig/ExternalDataService.swift5
-rw-r--r--DomainDig/HTTPHeadersService.swift5
-rw-r--r--DomainDig/IntegrationService.swift74
-rw-r--r--DomainDig/LookupRuntime.swift10
-rw-r--r--DomainDig/PortScanService.swift5
-rw-r--r--DomainDig/SweepActivityController.swift39
-rw-r--r--DomainDigShareExtension/ShareViewController.swift26
9 files changed, 129 insertions, 67 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 6b4c25c..c132d2a 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -591,7 +591,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -628,7 +628,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
@@ -657,7 +657,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -686,7 +686,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
@@ -715,7 +715,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -744,7 +744,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
diff --git a/DomainDig/DomainDigIntents.swift b/DomainDig/DomainDigIntents.swift
index 8b46aa7..70b9e67 100644
--- a/DomainDig/DomainDigIntents.swift
+++ b/DomainDig/DomainDigIntents.swift
@@ -6,13 +6,13 @@ import Foundation
/// `DomainReportBuilder`) and returns a concise summary. Usable from
/// Shortcuts, Spotlight, the Action button, and Siri.
struct InspectDomainIntent: AppIntent {
- static var title: LocalizedStringResource = "Inspect Domain"
- static var description = IntentDescription(
+ static let title: LocalizedStringResource = "Inspect Domain"
+ static let description = IntentDescription(
"Run a DomainDig inspection and return a summary of availability, risk, TLS, email security, and certificate health."
)
// Read-only inspection; no need to foreground the app.
- static var openAppWhenRun = false
+ static let openAppWhenRun = false
@Parameter(
title: "Domain",
@@ -44,6 +44,7 @@ struct InspectDomainIntent: AppIntent {
}
/// Multi-line summary suitable for a returned Shortcuts text value.
+ @MainActor
static func summaryText(for report: DomainReport) -> String {
let dnssec: String
switch report.dns.dnssecSigned {
@@ -68,6 +69,7 @@ struct InspectDomainIntent: AppIntent {
}
/// Short spoken/dialog line for Siri and the Shortcuts result banner.
+ @MainActor
static func spokenSummary(for report: DomainReport) -> String {
"\(report.domain) is \(report.availability.rawValue). Risk \(report.riskAssessment.level.title.lowercased()), health \(report.health.title.lowercased())."
}
@@ -89,12 +91,12 @@ enum InspectDomainError: Error, CustomLocalizedStringResourceConvertible {
/// existing view-model path (premium limits, monitoring, history linking,
/// cloud-sync recording, and the paywall when over the free limit).
struct AddToWatchlistIntent: AppIntent {
- static var title: LocalizedStringResource = "Add Domain to Watchlist"
- static var description = IntentDescription(
+ static let title: LocalizedStringResource = "Add Domain to Watchlist"
+ static let description = IntentDescription(
"Open DomainDig and add a domain to your watchlist."
)
- static var openAppWhenRun = true
+ static let openAppWhenRun = true
@Parameter(
title: "Domain",
@@ -128,12 +130,12 @@ struct AddToWatchlistIntent: AppIntent {
/// through the existing view-model batch path (`refreshAllTrackedDomains`), which
/// enforces the batch feature gate and surfaces the paywall when needed.
struct RunSweepIntent: AppIntent {
- static var title: LocalizedStringResource = "Run Watchlist Sweep"
- static var description = IntentDescription(
+ static let title: LocalizedStringResource = "Run Watchlist Sweep"
+ static let description = IntentDescription(
"Open DomainDig and re-inspect every domain on your watchlist."
)
- static var openAppWhenRun = true
+ static let openAppWhenRun = true
@MainActor
func perform() async throws -> some IntentResult {
diff --git a/DomainDig/ExternalDataService.swift b/DomainDig/ExternalDataService.swift
index 43cc489..8f5b07e 100644
--- a/DomainDig/ExternalDataService.swift
+++ b/DomainDig/ExternalDataService.swift
@@ -738,7 +738,10 @@ actor ExternalDataService {
}
}
- private static let iso8601DateFormatter: ISO8601DateFormatter = {
+ // ISO8601DateFormatter is documented thread-safe ("ISO8601DateFormatter is
+ // thread-safe" — Apple docs), so sharing one instance across contexts is
+ // sound; the annotation records that the compiler cannot see it. (#27)
+ private nonisolated(unsafe) static let iso8601DateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
diff --git a/DomainDig/HTTPHeadersService.swift b/DomainDig/HTTPHeadersService.swift
index 4bf175a..bbc2842 100644
--- a/DomainDig/HTTPHeadersService.swift
+++ b/DomainDig/HTTPHeadersService.swift
@@ -103,7 +103,10 @@ struct HTTPHeadersService {
}
private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate {
- private(set) var metrics: URLSessionTaskMetrics?
+ // Written from the session's delegate queue, read only after the request
+ // has completed — URLSession guarantees didFinishCollecting is delivered
+ // before the task finishes, so the accesses are sequenced. (#27)
+ nonisolated(unsafe) private(set) var metrics: URLSessionTaskMetrics?
func urlSession(
_ _: URLSession,
diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift
index de24c23..090f564 100644
--- a/DomainDig/IntegrationService.swift
+++ b/DomainDig/IntegrationService.swift
@@ -1,5 +1,6 @@
import Foundation
import Network
+import os
import Observation
import Security
@@ -749,11 +750,17 @@ private enum SMTPClient {
try await channel.sendRaw(body + "\r\n.\r\n")
_ = try await channel.readResponse(expecting: [250])
_ = try await channel.sendCommand("QUIT", expecting: [221])
- channel.cancel()
+ await channel.cancel()
}
}
-private final class SMTPChannel {
+/// An actor, not a MainActor class. The previous shape inherited the project's
+/// MainActor default while running its receive loop on a background dispatch
+/// queue, so `parsedLines`/`lineWaiters`/`receiveBuffer` were declared
+/// main-actor-protected and mutated off it — concurrent mutation while resuming
+/// a `CheckedContinuation` can double-resume, which traps. The actor serialises
+/// all of it and forces the Network callbacks to hop in explicitly. (Issue #27.)
+private actor SMTPChannel {
private let connection: NWConnection
private var parsedLines: [String] = []
private var lineWaiters: [CheckedContinuation<String, Error>] = []
@@ -765,25 +772,35 @@ private final class SMTPChannel {
func start() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
- connection.stateUpdateHandler = { [weak self] state in
+ // The state handler can fire `.ready` and later `.failed` (or
+ // `.failed` twice); resuming a continuation twice traps. The lock
+ // also keeps the closure Sendable-clean without touching actor state
+ // from the connection's queue.
+ let hasResumed = OSAllocatedUnfairLock(initialState: false)
+ connection.stateUpdateHandler = { state in
+ let isFirst: () -> Bool = {
+ hasResumed.withLock { resumed in
+ if resumed { return false }
+ resumed = true
+ return true
+ }
+ }
switch state {
case .ready:
- self?.scheduleReceiveLoop()
- continuation.resume()
+ if isFirst() { continuation.resume() }
case .failed(let error):
- continuation.resume(throwing: error)
+ if isFirst() { continuation.resume(throwing: error) }
+ case .cancelled:
+ if isFirst() { continuation.resume(throwing: IntegrationError.streamClosed) }
default:
break
}
}
connection.start(queue: .global(qos: .utility))
}
- }
-
- private func scheduleReceiveLoop() {
- DispatchQueue.global(qos: .utility).async { [weak self] in
- self?.startReceiveLoop()
- }
+ // Started from the actor once the connection is ready, replacing the old
+ // dispatch-queue hop. TCP buffers anything that arrives in the gap.
+ startReceiveLoop()
}
func cancel() {
@@ -845,26 +862,33 @@ private final class SMTPChannel {
}
private func startReceiveLoop() {
+ // The completion runs on the connection's queue; hop back onto the
+ // actor before touching any state.
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
guard let self else { return }
-
- if let error {
- self.failWaiters(with: error)
- return
+ Task {
+ await self.handleReceive(data: data, isComplete: isComplete, error: error)
}
+ }
+ }
- if let data, !data.isEmpty {
- self.receiveBuffer.append(data)
- self.flushBuffer()
- }
+ private func handleReceive(data: Data?, isComplete: Bool, error: Error?) {
+ if let error {
+ failWaiters(with: error)
+ return
+ }
- if isComplete {
- self.failWaiters(with: IntegrationError.streamClosed)
- return
- }
+ if let data, !data.isEmpty {
+ receiveBuffer.append(data)
+ flushBuffer()
+ }
- self.startReceiveLoop()
+ if isComplete {
+ failWaiters(with: IntegrationError.streamClosed)
+ return
}
+
+ startReceiveLoop()
}
private func flushBuffer() {
diff --git a/DomainDig/LookupRuntime.swift b/DomainDig/LookupRuntime.swift
index 5db3134..7d9a8bd 100644
--- a/DomainDig/LookupRuntime.swift
+++ b/DomainDig/LookupRuntime.swift
@@ -1,10 +1,18 @@
import Foundation
-struct CachedLookupResult<Value> {
+/// Opted out of the project's MainActor default isolation: this is a plain
+/// value pair constructed inside `actor LookupRuntime`, and a MainActor-bound
+/// memberwise init cannot be called from there under Swift 6.
+nonisolated struct CachedLookupResult<Value> {
let value: Value
let source: LookupResultSource
}
+/// Opting out of MainActor isolation also opted out of the implicit
+/// Sendable that globally-isolated types get, which is what lets this cross
+/// from `actor LookupRuntime` back to its callers.
+extension CachedLookupResult: Sendable where Value: Sendable {}
+
actor LookupRuntime {
static let shared = LookupRuntime()
diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift
index 808b38a..31bf298 100644
--- a/DomainDig/PortScanService.swift
+++ b/DomainDig/PortScanService.swift
@@ -106,7 +106,10 @@ struct PortScanService {
}
}
- private static func printableBanner(from data: Data?, error: Error?) -> String? {
+ /// Pure data transformation, called from the connection's background queue —
+ /// `nonisolated` opts it out of the project's MainActor default, which would
+ /// otherwise make this call a data-race diagnostic under Swift 6.
+ private nonisolated static func printableBanner(from data: Data?, error: Error?) -> String? {
guard error == nil,
let data,
!data.isEmpty,
diff --git a/DomainDig/SweepActivityController.swift b/DomainDig/SweepActivityController.swift
index a00bb2f..e6d179e 100644
--- a/DomainDig/SweepActivityController.swift
+++ b/DomainDig/SweepActivityController.swift
@@ -2,11 +2,18 @@ import ActivityKit
import Foundation
/// Starts, updates, and ends the sweep Live Activity around a batch run.
+///
+/// Holds the activity's `id` (a Sendable `String`) rather than the
+/// `Activity` object itself. `Activity` is not Sendable, and sending the
+/// stored reference into the fire-and-forget update task while `self` still
+/// held it was a Swift 6 region-isolation violation (issue #27). Each task
+/// re-resolves the activity via `Activity.activities`, ActivityKit's
+/// sanctioned lookup, so nothing non-Sendable crosses an isolation boundary.
@MainActor
final class SweepActivityController {
static let shared = SweepActivityController()
- private var activity: Activity<SweepActivityAttributes>?
+ private var activityID: String?
private init() { /* Singleton; use the shared instance. */ }
@@ -23,14 +30,15 @@ final class SweepActivityController {
changed: 0,
warnings: 0
)
- activity = try? Activity.request(
+ let activity = try? Activity.request(
attributes: SweepActivityAttributes(title: title, startedAt: Date()),
content: ActivityContent(state: state, staleDate: nil)
)
+ activityID = activity?.id
}
func update(completed: Int, total: Int, currentDomain: String?) {
- guard let activity else { return }
+ guard let activityID else { return }
let state = SweepActivityAttributes.ContentState(
completed: completed,
total: total,
@@ -39,25 +47,32 @@ final class SweepActivityController {
warnings: 0
)
Task {
+ guard let activity = Self.activity(withID: activityID) else { return }
await activity.update(ActivityContent(state: state, staleDate: nil))
}
}
func end(changed: Int, warnings: Int, immediately: Bool = false) {
- guard let activity else { return }
- self.activity = nil
- let state = SweepActivityAttributes.ContentState(
- completed: activity.content.state.total,
- total: activity.content.state.total,
- currentDomain: nil,
- changed: changed,
- warnings: warnings
- )
+ guard let activityID else { return }
+ self.activityID = nil
Task {
+ guard let activity = Self.activity(withID: activityID) else { return }
+ let total = activity.content.state.total
+ let state = SweepActivityAttributes.ContentState(
+ completed: total,
+ total: total,
+ currentDomain: nil,
+ changed: changed,
+ warnings: warnings
+ )
await activity.end(
ActivityContent(state: state, staleDate: nil),
dismissalPolicy: immediately ? .immediate : .after(Date().addingTimeInterval(60))
)
}
}
+
+ private nonisolated static func activity(withID id: String) -> Activity<SweepActivityAttributes>? {
+ Activity<SweepActivityAttributes>.activities.first { $0.id == id }
+ }
}
diff --git a/DomainDigShareExtension/ShareViewController.swift b/DomainDigShareExtension/ShareViewController.swift
index 411f9e7..1243892 100644
--- a/DomainDigShareExtension/ShareViewController.swift
+++ b/DomainDigShareExtension/ShareViewController.swift
@@ -24,14 +24,17 @@ final class ShareViewController: UIViewController {
label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24)
])
- extractSharedDomain { [weak self] domain in
- DispatchQueue.main.async {
- self?.finish(domain: domain)
- }
+ // Task inherits this view controller's MainActor context, so finish()
+ // lands back on main without a manual dispatch. The continuation form
+ // also avoids sending a non-Sendable completion into loadItem's
+ // @Sendable handler. (#27)
+ Task { [weak self] in
+ let domain = await self?.extractSharedDomain()
+ self?.finish(domain: domain ?? nil)
}
}
- private func extractSharedDomain(completion: @escaping (String?) -> Void) {
+ private func extractSharedDomain() async -> String? {
let providers = (extensionContext?.inputItems ?? [])
.compactMap { $0 as? NSExtensionItem }
.flatMap { $0.attachments ?? [] }
@@ -39,14 +42,15 @@ final class ShareViewController: UIViewController {
guard let provider = providers.first(where: {
$0.hasItemConformingToTypeIdentifier(UTType.url.identifier)
}) else {
- completion(nil)
- return
+ return nil
}
- provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
- let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) }
- let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines)
- completion(host?.isEmpty == false ? host : nil)
+ return await withCheckedContinuation { continuation in
+ provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
+ let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) }
+ let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines)
+ continuation.resume(returning: host?.isEmpty == false ? host : nil)
+ }
}
}