Commit 872583eca8
Unsigned
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +6 −6
| @@ -591,7 +591,7 @@ | ||
| 591 | 591 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
| 592 | 592 | SWIFT_EMIT_LOC_STRINGS = YES; |
| 593 | 593 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; |
| 594 | SWIFT_VERSION = 5.0; | |
| 594 | SWIFT_VERSION = 6.0; | |
| 595 | 595 | TARGETED_DEVICE_FAMILY = "1,2"; |
| 596 | 596 | }; |
| 597 | 597 | name = Debug; |
| @@ -628,7 +628,7 @@ | ||
| 628 | 628 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
| 629 | 629 | SWIFT_EMIT_LOC_STRINGS = YES; |
| 630 | 630 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; |
| 631 | SWIFT_VERSION = 5.0; | |
| 631 | SWIFT_VERSION = 6.0; | |
| 632 | 632 | TARGETED_DEVICE_FAMILY = "1,2"; |
| 633 | 633 | }; |
| 634 | 634 | name = Release; |
| @@ -657,7 +657,7 @@ | ||
| 657 | 657 | SWIFT_APPROACHABLE_CONCURRENCY = YES; |
| 658 | 658 | SWIFT_EMIT_LOC_STRINGS = YES; |
| 659 | 659 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; |
| 660 | SWIFT_VERSION = 5.0; | |
| 660 | SWIFT_VERSION = 6.0; | |
| 661 | 661 | TARGETED_DEVICE_FAMILY = "1,2"; |
| 662 | 662 | }; |
| 663 | 663 | name = Debug; |
| @@ -686,7 +686,7 @@ | ||
| 686 | 686 | SWIFT_APPROACHABLE_CONCURRENCY = YES; |
| 687 | 687 | SWIFT_EMIT_LOC_STRINGS = YES; |
| 688 | 688 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; |
| 689 | SWIFT_VERSION = 5.0; | |
| 689 | SWIFT_VERSION = 6.0; | |
| 690 | 690 | TARGETED_DEVICE_FAMILY = "1,2"; |
| 691 | 691 | }; |
| 692 | 692 | name = Release; |
| @@ -715,7 +715,7 @@ | ||
| 715 | 715 | SWIFT_APPROACHABLE_CONCURRENCY = YES; |
| 716 | 716 | SWIFT_EMIT_LOC_STRINGS = YES; |
| 717 | 717 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; |
| 718 | SWIFT_VERSION = 5.0; | |
| 718 | SWIFT_VERSION = 6.0; | |
| 719 | 719 | TARGETED_DEVICE_FAMILY = "1,2"; |
| 720 | 720 | }; |
| 721 | 721 | name = Debug; |
| @@ -744,7 +744,7 @@ | ||
| 744 | 744 | SWIFT_APPROACHABLE_CONCURRENCY = YES; |
| 745 | 745 | SWIFT_EMIT_LOC_STRINGS = YES; |
| 746 | 746 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; |
| 747 | SWIFT_VERSION = 5.0; | |
| 747 | SWIFT_VERSION = 6.0; | |
| 748 | 748 | TARGETED_DEVICE_FAMILY = "1,2"; |
| 749 | 749 | }; |
| 750 | 750 | name = Release; |
DomainDig/DomainDigIntents.swift +11 −9
| @@ -6,13 +6,13 @@ import Foundation | ||
| 6 | 6 | /// `DomainReportBuilder`) and returns a concise summary. Usable from |
| 7 | 7 | /// Shortcuts, Spotlight, the Action button, and Siri. |
| 8 | 8 | struct InspectDomainIntent: AppIntent { |
| 9 | static var title: LocalizedStringResource = "Inspect Domain" | |
| 10 | static var description = IntentDescription( | |
| 9 | static let title: LocalizedStringResource = "Inspect Domain" | |
| 10 | static let description = IntentDescription( | |
| 11 | 11 | "Run a DomainDig inspection and return a summary of availability, risk, TLS, email security, and certificate health." |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | // Read-only inspection; no need to foreground the app. |
| 15 | static var openAppWhenRun = false | |
| 15 | static let openAppWhenRun = false | |
| 16 | 16 | |
| 17 | 17 | @Parameter( |
| 18 | 18 | title: "Domain", |
| @@ -44,6 +44,7 @@ struct InspectDomainIntent: AppIntent { | ||
| 44 | 44 | } |
| 45 | 45 | |
| 46 | 46 | /// Multi-line summary suitable for a returned Shortcuts text value. |
| 47 | @MainActor | |
| 47 | 48 | static func summaryText(for report: DomainReport) -> String { |
| 48 | 49 | let dnssec: String |
| 49 | 50 | switch report.dns.dnssecSigned { |
| @@ -68,6 +69,7 @@ struct InspectDomainIntent: AppIntent { | ||
| 68 | 69 | } |
| 69 | 70 | |
| 70 | 71 | /// Short spoken/dialog line for Siri and the Shortcuts result banner. |
| 72 | @MainActor | |
| 71 | 73 | static func spokenSummary(for report: DomainReport) -> String { |
| 72 | 74 | "\(report.domain) is \(report.availability.rawValue). Risk \(report.riskAssessment.level.title.lowercased()), health \(report.health.title.lowercased())." |
| 73 | 75 | } |
| @@ -89,12 +91,12 @@ enum InspectDomainError: Error, CustomLocalizedStringResourceConvertible { | ||
| 89 | 91 | /// existing view-model path (premium limits, monitoring, history linking, |
| 90 | 92 | /// cloud-sync recording, and the paywall when over the free limit). |
| 91 | 93 | struct AddToWatchlistIntent: AppIntent { |
| 92 | static var title: LocalizedStringResource = "Add Domain to Watchlist" | |
| 93 | static var description = IntentDescription( | |
| 94 | static let title: LocalizedStringResource = "Add Domain to Watchlist" | |
| 95 | static let description = IntentDescription( | |
| 94 | 96 | "Open DomainDig and add a domain to your watchlist." |
| 95 | 97 | ) |
| 96 | 98 | |
| 97 | static var openAppWhenRun = true | |
| 99 | static let openAppWhenRun = true | |
| 98 | 100 | |
| 99 | 101 | @Parameter( |
| 100 | 102 | title: "Domain", |
| @@ -128,12 +130,12 @@ struct AddToWatchlistIntent: AppIntent { | ||
| 128 | 130 | /// through the existing view-model batch path (`refreshAllTrackedDomains`), which |
| 129 | 131 | /// enforces the batch feature gate and surfaces the paywall when needed. |
| 130 | 132 | struct RunSweepIntent: AppIntent { |
| 131 | static var title: LocalizedStringResource = "Run Watchlist Sweep" | |
| 132 | static var description = IntentDescription( | |
| 133 | static let title: LocalizedStringResource = "Run Watchlist Sweep" | |
| 134 | static let description = IntentDescription( | |
| 133 | 135 | "Open DomainDig and re-inspect every domain on your watchlist." |
| 134 | 136 | ) |
| 135 | 137 | |
| 136 | static var openAppWhenRun = true | |
| 138 | static let openAppWhenRun = true | |
| 137 | 139 | |
| 138 | 140 | @MainActor |
| 139 | 141 | func perform() async throws -> some IntentResult { |
DomainDig/ExternalDataService.swift +4 −1
| @@ -738,7 +738,10 @@ actor ExternalDataService { | ||
| 738 | 738 | } |
| 739 | 739 | } |
| 740 | 740 | |
| 741 | private static let iso8601DateFormatter: ISO8601DateFormatter = { | |
| 741 | // ISO8601DateFormatter is documented thread-safe ("ISO8601DateFormatter is | |
| 742 | // thread-safe" — Apple docs), so sharing one instance across contexts is | |
| 743 | // sound; the annotation records that the compiler cannot see it. (#27) | |
| 744 | private nonisolated(unsafe) static let iso8601DateFormatter: ISO8601DateFormatter = { | |
| 742 | 745 | let formatter = ISO8601DateFormatter() |
| 743 | 746 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] |
| 744 | 747 | return formatter |
DomainDig/HTTPHeadersService.swift +4 −1
| @@ -103,7 +103,10 @@ struct HTTPHeadersService { | ||
| 103 | 103 | } |
| 104 | 104 | |
| 105 | 105 | private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate { |
| 106 | private(set) var metrics: URLSessionTaskMetrics? | |
| 106 | // Written from the session's delegate queue, read only after the request | |
| 107 | // has completed — URLSession guarantees didFinishCollecting is delivered | |
| 108 | // before the task finishes, so the accesses are sequenced. (#27) | |
| 109 | nonisolated(unsafe) private(set) var metrics: URLSessionTaskMetrics? | |
| 107 | 110 | |
| 108 | 111 | func urlSession( |
| 109 | 112 | _ _: URLSession, |
DomainDig/IntegrationService.swift +49 −25
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Network |
| 3 | import os | |
| 3 | 4 | import Observation |
| 4 | 5 | import Security |
| 5 | 6 | |
| @@ -749,11 +750,17 @@ private enum SMTPClient { | ||
| 749 | 750 | try await channel.sendRaw(body + "\r\n.\r\n") |
| 750 | 751 | _ = try await channel.readResponse(expecting: [250]) |
| 751 | 752 | _ = try await channel.sendCommand("QUIT", expecting: [221]) |
| 752 | channel.cancel() | |
| 753 | await channel.cancel() | |
| 753 | 754 | } |
| 754 | 755 | } |
| 755 | 756 | |
| 756 | private final class SMTPChannel { | |
| 757 | /// An actor, not a MainActor class. The previous shape inherited the project's | |
| 758 | /// MainActor default while running its receive loop on a background dispatch | |
| 759 | /// queue, so `parsedLines`/`lineWaiters`/`receiveBuffer` were declared | |
| 760 | /// main-actor-protected and mutated off it — concurrent mutation while resuming | |
| 761 | /// a `CheckedContinuation` can double-resume, which traps. The actor serialises | |
| 762 | /// all of it and forces the Network callbacks to hop in explicitly. (Issue #27.) | |
| 763 | private actor SMTPChannel { | |
| 757 | 764 | private let connection: NWConnection |
| 758 | 765 | private var parsedLines: [String] = [] |
| 759 | 766 | private var lineWaiters: [CheckedContinuation<String, Error>] = [] |
| @@ -765,25 +772,35 @@ private final class SMTPChannel { | ||
| 765 | 772 | |
| 766 | 773 | func start() async throws { |
| 767 | 774 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in |
| 768 | connection.stateUpdateHandler = { [weak self] state in | |
| 775 | // The state handler can fire `.ready` and later `.failed` (or | |
| 776 | // `.failed` twice); resuming a continuation twice traps. The lock | |
| 777 | // also keeps the closure Sendable-clean without touching actor state | |
| 778 | // from the connection's queue. | |
| 779 | let hasResumed = OSAllocatedUnfairLock(initialState: false) | |
| 780 | connection.stateUpdateHandler = { state in | |
| 781 | let isFirst: () -> Bool = { | |
| 782 | hasResumed.withLock { resumed in | |
| 783 | if resumed { return false } | |
| 784 | resumed = true | |
| 785 | return true | |
| 786 | } | |
| 787 | } | |
| 769 | 788 | switch state { |
| 770 | 789 | case .ready: |
| 771 | self?.scheduleReceiveLoop() | |
| 772 | continuation.resume() | |
| 790 | if isFirst() { continuation.resume() } | |
| 773 | 791 | case .failed(let error): |
| 774 | continuation.resume(throwing: error) | |
| 792 | if isFirst() { continuation.resume(throwing: error) } | |
| 793 | case .cancelled: | |
| 794 | if isFirst() { continuation.resume(throwing: IntegrationError.streamClosed) } | |
| 775 | 795 | default: |
| 776 | 796 | break |
| 777 | 797 | } |
| 778 | 798 | } |
| 779 | 799 | connection.start(queue: .global(qos: .utility)) |
| 780 | 800 | } |
| 781 | } | |
| 782 | ||
| 783 | private func scheduleReceiveLoop() { | |
| 784 | DispatchQueue.global(qos: .utility).async { [weak self] in | |
| 785 | self?.startReceiveLoop() | |
| 786 | } | |
| 801 | // Started from the actor once the connection is ready, replacing the old | |
| 802 | // dispatch-queue hop. TCP buffers anything that arrives in the gap. | |
| 803 | startReceiveLoop() | |
| 787 | 804 | } |
| 788 | 805 | |
| 789 | 806 | func cancel() { |
| @@ -845,26 +862,33 @@ private final class SMTPChannel { | ||
| 845 | 862 | } |
| 846 | 863 | |
| 847 | 864 | private func startReceiveLoop() { |
| 865 | // The completion runs on the connection's queue; hop back onto the | |
| 866 | // actor before touching any state. | |
| 848 | 867 | connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in |
| 849 | 868 | guard let self else { return } |
| 850 | ||
| 851 | if let error { | |
| 852 | self.failWaiters(with: error) | |
| 853 | return | |
| 869 | Task { | |
| 870 | await self.handleReceive(data: data, isComplete: isComplete, error: error) | |
| 854 | 871 | } |
| 872 | } | |
| 873 | } | |
| 855 | 874 | |
| 856 | if let data, !data.isEmpty { | |
| 857 | self.receiveBuffer.append(data) | |
| 858 | self.flushBuffer() | |
| 859 | } | |
| 875 | private func handleReceive(data: Data?, isComplete: Bool, error: Error?) { | |
| 876 | if let error { | |
| 877 | failWaiters(with: error) | |
| 878 | return | |
| 879 | } | |
| 860 | 880 | |
| 861 | if isComplete { | |
| 862 | self.failWaiters(with: IntegrationError.streamClosed) | |
| 863 | return | |
| 864 | } | |
| 881 | if let data, !data.isEmpty { | |
| 882 | receiveBuffer.append(data) | |
| 883 | flushBuffer() | |
| 884 | } | |
| 865 | 885 | |
| 866 | self.startReceiveLoop() | |
| 886 | if isComplete { | |
| 887 | failWaiters(with: IntegrationError.streamClosed) | |
| 888 | return | |
| 867 | 889 | } |
| 890 | ||
| 891 | startReceiveLoop() | |
| 868 | 892 | } |
| 869 | 893 | |
| 870 | 894 | private func flushBuffer() { |
DomainDig/LookupRuntime.swift +9 −1
| @@ -1,10 +1,18 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | struct CachedLookupResult<Value> { | |
| 3 | /// Opted out of the project's MainActor default isolation: this is a plain | |
| 4 | /// value pair constructed inside `actor LookupRuntime`, and a MainActor-bound | |
| 5 | /// memberwise init cannot be called from there under Swift 6. | |
| 6 | nonisolated struct CachedLookupResult<Value> { | |
| 4 | 7 | let value: Value |
| 5 | 8 | let source: LookupResultSource |
| 6 | 9 | } |
| 7 | 10 | |
| 11 | /// Opting out of MainActor isolation also opted out of the implicit | |
| 12 | /// Sendable that globally-isolated types get, which is what lets this cross | |
| 13 | /// from `actor LookupRuntime` back to its callers. | |
| 14 | extension CachedLookupResult: Sendable where Value: Sendable {} | |
| 15 | ||
| 8 | 16 | actor LookupRuntime { |
| 9 | 17 | static let shared = LookupRuntime() |
| 10 | 18 | |
DomainDig/PortScanService.swift +4 −1
| @@ -106,7 +106,10 @@ struct PortScanService { | ||
| 106 | 106 | } |
| 107 | 107 | } |
| 108 | 108 | |
| 109 | private static func printableBanner(from data: Data?, error: Error?) -> String? { | |
| 109 | /// Pure data transformation, called from the connection's background queue — | |
| 110 | /// `nonisolated` opts it out of the project's MainActor default, which would | |
| 111 | /// otherwise make this call a data-race diagnostic under Swift 6. | |
| 112 | private nonisolated static func printableBanner(from data: Data?, error: Error?) -> String? { | |
| 110 | 113 | guard error == nil, |
| 111 | 114 | let data, |
| 112 | 115 | !data.isEmpty, |
DomainDig/SweepActivityController.swift +27 −12
| @@ -2,11 +2,18 @@ import ActivityKit | ||
| 2 | 2 | import Foundation |
| 3 | 3 | |
| 4 | 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. | |
| 5 | 12 | @MainActor |
| 6 | 13 | final class SweepActivityController { |
| 7 | 14 | static let shared = SweepActivityController() |
| 8 | 15 | |
| 9 | private var activity: Activity<SweepActivityAttributes>? | |
| 16 | private var activityID: String? | |
| 10 | 17 | |
| 11 | 18 | private init() { /* Singleton; use the shared instance. */ } |
| 12 | 19 | |
| @@ -23,14 +30,15 @@ final class SweepActivityController { | ||
| 23 | 30 | changed: 0, |
| 24 | 31 | warnings: 0 |
| 25 | 32 | ) |
| 26 | activity = try? Activity.request( | |
| 33 | let activity = try? Activity.request( | |
| 27 | 34 | attributes: SweepActivityAttributes(title: title, startedAt: Date()), |
| 28 | 35 | content: ActivityContent(state: state, staleDate: nil) |
| 29 | 36 | ) |
| 37 | activityID = activity?.id | |
| 30 | 38 | } |
| 31 | 39 | |
| 32 | 40 | func update(completed: Int, total: Int, currentDomain: String?) { |
| 33 | guard let activity else { return } | |
| 41 | guard let activityID else { return } | |
| 34 | 42 | let state = SweepActivityAttributes.ContentState( |
| 35 | 43 | completed: completed, |
| 36 | 44 | total: total, |
| @@ -39,25 +47,32 @@ final class SweepActivityController { | ||
| 39 | 47 | warnings: 0 |
| 40 | 48 | ) |
| 41 | 49 | Task { |
| 50 | guard let activity = Self.activity(withID: activityID) else { return } | |
| 42 | 51 | await activity.update(ActivityContent(state: state, staleDate: nil)) |
| 43 | 52 | } |
| 44 | 53 | } |
| 45 | 54 | |
| 46 | 55 | func end(changed: Int, warnings: Int, immediately: Bool = false) { |
| 47 | guard let activity else { return } | |
| 48 | self.activity = nil | |
| 49 | let state = SweepActivityAttributes.ContentState( | |
| 50 | completed: activity.content.state.total, | |
| 51 | total: activity.content.state.total, | |
| 52 | currentDomain: nil, | |
| 53 | changed: changed, | |
| 54 | warnings: warnings | |
| 55 | ) | |
| 56 | guard let activityID else { return } | |
| 57 | self.activityID = nil | |
| 56 | 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 | ) | |
| 57 | 68 | await activity.end( |
| 58 | 69 | ActivityContent(state: state, staleDate: nil), |
| 59 | 70 | dismissalPolicy: immediately ? .immediate : .after(Date().addingTimeInterval(60)) |
| 60 | 71 | ) |
| 61 | 72 | } |
| 62 | 73 | } |
| 74 | ||
| 75 | private nonisolated static func activity(withID id: String) -> Activity<SweepActivityAttributes>? { | |
| 76 | Activity<SweepActivityAttributes>.activities.first { $0.id == id } | |
| 77 | } | |
| 63 | 78 | } |
DomainDigShareExtension/ShareViewController.swift +15 −11
| @@ -24,14 +24,17 @@ final class ShareViewController: UIViewController { | ||
| 24 | 24 | label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24) |
| 25 | 25 | ]) |
| 26 | 26 | |
| 27 | extractSharedDomain { [weak self] domain in | |
| 28 | DispatchQueue.main.async { | |
| 29 | self?.finish(domain: domain) | |
| 30 | } | |
| 27 | // Task inherits this view controller's MainActor context, so finish() | |
| 28 | // lands back on main without a manual dispatch. The continuation form | |
| 29 | // also avoids sending a non-Sendable completion into loadItem's | |
| 30 | // @Sendable handler. (#27) | |
| 31 | Task { [weak self] in | |
| 32 | let domain = await self?.extractSharedDomain() | |
| 33 | self?.finish(domain: domain ?? nil) | |
| 31 | 34 | } |
| 32 | 35 | } |
| 33 | 36 | |
| 34 | private func extractSharedDomain(completion: @escaping (String?) -> Void) { | |
| 37 | private func extractSharedDomain() async -> String? { | |
| 35 | 38 | let providers = (extensionContext?.inputItems ?? []) |
| 36 | 39 | .compactMap { $0 as? NSExtensionItem } |
| 37 | 40 | .flatMap { $0.attachments ?? [] } |
| @@ -39,14 +42,15 @@ final class ShareViewController: UIViewController { | ||
| 39 | 42 | guard let provider = providers.first(where: { |
| 40 | 43 | $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) |
| 41 | 44 | }) else { |
| 42 | completion(nil) | |
| 43 | return | |
| 45 | return nil | |
| 44 | 46 | } |
| 45 | 47 | |
| 46 | provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in | |
| 47 | let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } | |
| 48 | let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 49 | completion(host?.isEmpty == false ? host : nil) | |
| 48 | return await withCheckedContinuation { continuation in | |
| 49 | provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in | |
| 50 | let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } | |
| 51 | let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 52 | continuation.resume(returning: host?.isEmpty == false ? host : nil) | |
| 53 | } | |
| 50 | 54 | } |
| 51 | 55 | } |
| 52 | 56 | |