Commit 872583eca8

872583eca8b4e7ae6ef85917604d4a5257b9d125

parent: d3af0e7d51

Unsigned

cmc <hello@cleberg.net> · 2026-07-23 02:43 UTC

fix: adopt Swift 6 language mode; resolve all concurrency issues (#27)

All three product targets (app, widget, share extension) now build under
SWIFT_VERSION = 6.0 with zero errors and zero warnings. The UITests
target stays on 5.0: XCTestCase's nonisolated setUp/init overrides
conflict with the target's MainActor default isolation under 6, and test
tooling is not shipping code.

The original seven diagnostics, plus the layers Swift 6 mode surfaced
once those cleared:

- SMTPChannel is an actor. It was implicitly MainActor while running its
  receive loop on a background 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 state; Network
  callbacks hop in via Task. The start() continuation also gains an
  OSAllocatedUnfairLock resume-once guard: the state handler can fire
  .ready and later .failed, and resuming twice was a pre-existing trap of
  the same family.
- CachedLookupResult is nonisolated (a value pair built inside actor
  LookupRuntime cannot have a MainActor-bound memberwise init) with
  conditional Sendable — opting out of MainActor isolation also opted out
  of the implicit Sendable that globally-isolated types get.
- PortScanService.printableBanner is nonisolated: a pure transformation
  called from the connection's queue.
- SweepActivityController stores the activity's Sendable id instead of
  the non-Sendable Activity, re-resolving via Activity.activities inside
  each fire-and-forget task, so nothing non-Sendable crosses isolation.
- App Intents' static title/description/openAppWhenRun become lets
  (get-only protocol requirements; static var is shared mutable global
  state), and the summary helpers are @MainActor to match the model
  properties they read and the perform() implementations that call them.
- ExternalDataService's ISO8601DateFormatter is nonisolated(unsafe),
  citing Apple's documented thread-safety, rather than risking a parser
  behaviour change by switching APIs with no test coverage.
- TaskMetricsDelegate.metrics is nonisolated(unsafe): written on the
  session's delegate queue, read only after the request completes, and
  URLSession guarantees didFinishCollecting precedes task completion.
- The share extension extracts the host via async/withCheckedContinuation
  instead of sending a non-Sendable completion into loadItem's @Sendable
  handler; Task inherits the view controller's MainActor so the manual
  DispatchQueue.main hop goes too.

Validated: clean Swift 6 build of all product targets, and the full
enforced 11-test audit suite green on the floor runtime — Swift 6's
runtime isolation checks ran the app through every screen without a
trap.

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +6 −6
@@ -591,7 +591,7 @@
591 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; 591 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
592 SWIFT_EMIT_LOC_STRINGS = YES; 592 SWIFT_EMIT_LOC_STRINGS = YES;
593 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 593 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
594 SWIFT_VERSION = 5.0; 594 SWIFT_VERSION = 6.0;
595 TARGETED_DEVICE_FAMILY = "1,2"; 595 TARGETED_DEVICE_FAMILY = "1,2";
596 }; 596 };
597 name = Debug; 597 name = Debug;
@@ -628,7 +628,7 @@
628 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; 628 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
629 SWIFT_EMIT_LOC_STRINGS = YES; 629 SWIFT_EMIT_LOC_STRINGS = YES;
630 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 630 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
631 SWIFT_VERSION = 5.0; 631 SWIFT_VERSION = 6.0;
632 TARGETED_DEVICE_FAMILY = "1,2"; 632 TARGETED_DEVICE_FAMILY = "1,2";
633 }; 633 };
634 name = Release; 634 name = Release;
@@ -657,7 +657,7 @@
657 SWIFT_APPROACHABLE_CONCURRENCY = YES; 657 SWIFT_APPROACHABLE_CONCURRENCY = YES;
658 SWIFT_EMIT_LOC_STRINGS = YES; 658 SWIFT_EMIT_LOC_STRINGS = YES;
659 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 659 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
660 SWIFT_VERSION = 5.0; 660 SWIFT_VERSION = 6.0;
661 TARGETED_DEVICE_FAMILY = "1,2"; 661 TARGETED_DEVICE_FAMILY = "1,2";
662 }; 662 };
663 name = Debug; 663 name = Debug;
@@ -686,7 +686,7 @@
686 SWIFT_APPROACHABLE_CONCURRENCY = YES; 686 SWIFT_APPROACHABLE_CONCURRENCY = YES;
687 SWIFT_EMIT_LOC_STRINGS = YES; 687 SWIFT_EMIT_LOC_STRINGS = YES;
688 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 688 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
689 SWIFT_VERSION = 5.0; 689 SWIFT_VERSION = 6.0;
690 TARGETED_DEVICE_FAMILY = "1,2"; 690 TARGETED_DEVICE_FAMILY = "1,2";
691 }; 691 };
692 name = Release; 692 name = Release;
@@ -715,7 +715,7 @@
715 SWIFT_APPROACHABLE_CONCURRENCY = YES; 715 SWIFT_APPROACHABLE_CONCURRENCY = YES;
716 SWIFT_EMIT_LOC_STRINGS = YES; 716 SWIFT_EMIT_LOC_STRINGS = YES;
717 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 717 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
718 SWIFT_VERSION = 5.0; 718 SWIFT_VERSION = 6.0;
719 TARGETED_DEVICE_FAMILY = "1,2"; 719 TARGETED_DEVICE_FAMILY = "1,2";
720 }; 720 };
721 name = Debug; 721 name = Debug;
@@ -744,7 +744,7 @@
744 SWIFT_APPROACHABLE_CONCURRENCY = YES; 744 SWIFT_APPROACHABLE_CONCURRENCY = YES;
745 SWIFT_EMIT_LOC_STRINGS = YES; 745 SWIFT_EMIT_LOC_STRINGS = YES;
746 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 746 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
747 SWIFT_VERSION = 5.0; 747 SWIFT_VERSION = 6.0;
748 TARGETED_DEVICE_FAMILY = "1,2"; 748 TARGETED_DEVICE_FAMILY = "1,2";
749 }; 749 };
750 name = Release; 750 name = Release;
DomainDig/DomainDigIntents.swift +11 −9
@@ -6,13 +6,13 @@ import Foundation
6/// `DomainReportBuilder`) and returns a concise summary. Usable from 6/// `DomainReportBuilder`) and returns a concise summary. Usable from
7/// Shortcuts, Spotlight, the Action button, and Siri. 7/// Shortcuts, Spotlight, the Action button, and Siri.
8struct InspectDomainIntent: AppIntent { 8struct InspectDomainIntent: AppIntent {
9 static var title: LocalizedStringResource = "Inspect Domain" 9 static let title: LocalizedStringResource = "Inspect Domain"
10 static var description = IntentDescription( 10 static let description = IntentDescription(
11 "Run a DomainDig inspection and return a summary of availability, risk, TLS, email security, and certificate health." 11 "Run a DomainDig inspection and return a summary of availability, risk, TLS, email security, and certificate health."
12 ) 12 )
13 13
14 // Read-only inspection; no need to foreground the app. 14 // Read-only inspection; no need to foreground the app.
15 static var openAppWhenRun = false 15 static let openAppWhenRun = false
16 16
17 @Parameter( 17 @Parameter(
18 title: "Domain", 18 title: "Domain",
@@ -44,6 +44,7 @@ struct InspectDomainIntent: AppIntent {
44 } 44 }
45 45
46 /// Multi-line summary suitable for a returned Shortcuts text value. 46 /// Multi-line summary suitable for a returned Shortcuts text value.
47 @MainActor
47 static func summaryText(for report: DomainReport) -> String { 48 static func summaryText(for report: DomainReport) -> String {
48 let dnssec: String 49 let dnssec: String
49 switch report.dns.dnssecSigned { 50 switch report.dns.dnssecSigned {
@@ -68,6 +69,7 @@ struct InspectDomainIntent: AppIntent {
68 } 69 }
69 70
70 /// Short spoken/dialog line for Siri and the Shortcuts result banner. 71 /// Short spoken/dialog line for Siri and the Shortcuts result banner.
72 @MainActor
71 static func spokenSummary(for report: DomainReport) -> String { 73 static func spokenSummary(for report: DomainReport) -> String {
72 "\(report.domain) is \(report.availability.rawValue). Risk \(report.riskAssessment.level.title.lowercased()), health \(report.health.title.lowercased())." 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/// existing view-model path (premium limits, monitoring, history linking, 91/// existing view-model path (premium limits, monitoring, history linking,
90/// cloud-sync recording, and the paywall when over the free limit). 92/// cloud-sync recording, and the paywall when over the free limit).
91struct AddToWatchlistIntent: AppIntent { 93struct AddToWatchlistIntent: AppIntent {
92 static var title: LocalizedStringResource = "Add Domain to Watchlist" 94 static let title: LocalizedStringResource = "Add Domain to Watchlist"
93 static var description = IntentDescription( 95 static let description = IntentDescription(
94 "Open DomainDig and add a domain to your watchlist." 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 @Parameter( 101 @Parameter(
100 title: "Domain", 102 title: "Domain",
@@ -128,12 +130,12 @@ struct AddToWatchlistIntent: AppIntent {
128/// through the existing view-model batch path (`refreshAllTrackedDomains`), which 130/// through the existing view-model batch path (`refreshAllTrackedDomains`), which
129/// enforces the batch feature gate and surfaces the paywall when needed. 131/// enforces the batch feature gate and surfaces the paywall when needed.
130struct RunSweepIntent: AppIntent { 132struct RunSweepIntent: AppIntent {
131 static var title: LocalizedStringResource = "Run Watchlist Sweep" 133 static let title: LocalizedStringResource = "Run Watchlist Sweep"
132 static var description = IntentDescription( 134 static let description = IntentDescription(
133 "Open DomainDig and re-inspect every domain on your watchlist." 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 @MainActor 140 @MainActor
139 func perform() async throws -> some IntentResult { 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 let formatter = ISO8601DateFormatter() 745 let formatter = ISO8601DateFormatter()
743 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 746 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
744 return formatter 747 return formatter
DomainDig/HTTPHeadersService.swift +4 −1
@@ -103,7 +103,10 @@ struct HTTPHeadersService {
103} 103}
104 104
105private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate { 105private 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 func urlSession( 111 func urlSession(
109 _ _: URLSession, 112 _ _: URLSession,
DomainDig/IntegrationService.swift +49 −25
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import Network 2import Network
3import os
3import Observation 4import Observation
4import Security 5import Security
5 6
@@ -749,11 +750,17 @@ private enum SMTPClient {
749 try await channel.sendRaw(body + "\r\n.\r\n") 750 try await channel.sendRaw(body + "\r\n.\r\n")
750 _ = try await channel.readResponse(expecting: [250]) 751 _ = try await channel.readResponse(expecting: [250])
751 _ = try await channel.sendCommand("QUIT", expecting: [221]) 752 _ = try await channel.sendCommand("QUIT", expecting: [221])
752 channel.cancel() 753 await channel.cancel()
753 } 754 }
754} 755}
755 756
756private 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.)
763private actor SMTPChannel {
757 private let connection: NWConnection 764 private let connection: NWConnection
758 private var parsedLines: [String] = [] 765 private var parsedLines: [String] = []
759 private var lineWaiters: [CheckedContinuation<String, Error>] = [] 766 private var lineWaiters: [CheckedContinuation<String, Error>] = []
@@ -765,25 +772,35 @@ private final class SMTPChannel {
765 772
766 func start() async throws { 773 func start() async throws {
767 try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in 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 switch state { 788 switch state {
770 case .ready: 789 case .ready:
771 self?.scheduleReceiveLoop() 790 if isFirst() { continuation.resume() }
772 continuation.resume()
773 case .failed(let error): 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 default: 795 default:
776 break 796 break
777 } 797 }
778 } 798 }
779 connection.start(queue: .global(qos: .utility)) 799 connection.start(queue: .global(qos: .utility))
780 } 800 }
781 } 801 // Started from the actor once the connection is ready, replacing the old
782 802 // dispatch-queue hop. TCP buffers anything that arrives in the gap.
783 private func scheduleReceiveLoop() { 803 startReceiveLoop()
784 DispatchQueue.global(qos: .utility).async { [weak self] in
785 self?.startReceiveLoop()
786 }
787 } 804 }
788 805
789 func cancel() { 806 func cancel() {
@@ -845,26 +862,33 @@ private final class SMTPChannel {
845 } 862 }
846 863
847 private func startReceiveLoop() { 864 private func startReceiveLoop() {
865 // The completion runs on the connection's queue; hop back onto the
866 // actor before touching any state.
848 connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in 867 connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
849 guard let self else { return } 868 guard let self else { return }
850 869 Task {
851 if let error { 870 await self.handleReceive(data: data, isComplete: isComplete, error: error)
852 self.failWaiters(with: error)
853 return
854 } 871 }
872 }
873 }
855 874
856 if let data, !data.isEmpty { 875 private func handleReceive(data: Data?, isComplete: Bool, error: Error?) {
857 self.receiveBuffer.append(data) 876 if let error {
858 self.flushBuffer() 877 failWaiters(with: error)
859 } 878 return
879 }
860 880
861 if isComplete { 881 if let data, !data.isEmpty {
862 self.failWaiters(with: IntegrationError.streamClosed) 882 receiveBuffer.append(data)
863 return 883 flushBuffer()
864 } 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 private func flushBuffer() { 894 private func flushBuffer() {
DomainDig/LookupRuntime.swift +9 −1
@@ -1,10 +1,18 @@
1import Foundation 1import Foundation
2 2
3struct 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.
6nonisolated struct CachedLookupResult<Value> {
4 let value: Value 7 let value: Value
5 let source: LookupResultSource 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.
14extension CachedLookupResult: Sendable where Value: Sendable {}
15
8actor LookupRuntime { 16actor LookupRuntime {
9 static let shared = LookupRuntime() 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 guard error == nil, 113 guard error == nil,
111 let data, 114 let data,
112 !data.isEmpty, 115 !data.isEmpty,
DomainDig/SweepActivityController.swift +27 −12
@@ -2,11 +2,18 @@ import ActivityKit
2import Foundation 2import Foundation
3 3
4/// Starts, updates, and ends the sweep Live Activity around a batch run. 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@MainActor 12@MainActor
6final class SweepActivityController { 13final class SweepActivityController {
7 static let shared = SweepActivityController() 14 static let shared = SweepActivityController()
8 15
9 private var activity: Activity<SweepActivityAttributes>? 16 private var activityID: String?
10 17
11 private init() { /* Singleton; use the shared instance. */ } 18 private init() { /* Singleton; use the shared instance. */ }
12 19
@@ -23,14 +30,15 @@ final class SweepActivityController {
23 changed: 0, 30 changed: 0,
24 warnings: 0 31 warnings: 0
25 ) 32 )
26 activity = try? Activity.request( 33 let activity = try? Activity.request(
27 attributes: SweepActivityAttributes(title: title, startedAt: Date()), 34 attributes: SweepActivityAttributes(title: title, startedAt: Date()),
28 content: ActivityContent(state: state, staleDate: nil) 35 content: ActivityContent(state: state, staleDate: nil)
29 ) 36 )
37 activityID = activity?.id
30 } 38 }
31 39
32 func update(completed: Int, total: Int, currentDomain: String?) { 40 func update(completed: Int, total: Int, currentDomain: String?) {
33 guard let activity else { return } 41 guard let activityID else { return }
34 let state = SweepActivityAttributes.ContentState( 42 let state = SweepActivityAttributes.ContentState(
35 completed: completed, 43 completed: completed,
36 total: total, 44 total: total,
@@ -39,25 +47,32 @@ final class SweepActivityController {
39 warnings: 0 47 warnings: 0
40 ) 48 )
41 Task { 49 Task {
50 guard let activity = Self.activity(withID: activityID) else { return }
42 await activity.update(ActivityContent(state: state, staleDate: nil)) 51 await activity.update(ActivityContent(state: state, staleDate: nil))
43 } 52 }
44 } 53 }
45 54
46 func end(changed: Int, warnings: Int, immediately: Bool = false) { 55 func end(changed: Int, warnings: Int, immediately: Bool = false) {
47 guard let activity else { return } 56 guard let activityID else { return }
48 self.activity = nil 57 self.activityID = 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 Task { 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 await activity.end( 68 await activity.end(
58 ActivityContent(state: state, staleDate: nil), 69 ActivityContent(state: state, staleDate: nil),
59 dismissalPolicy: immediately ? .immediate : .after(Date().addingTimeInterval(60)) 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 label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24) 24 label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24)
25 ]) 25 ])
26 26
27 extractSharedDomain { [weak self] domain in 27 // Task inherits this view controller's MainActor context, so finish()
28 DispatchQueue.main.async { 28 // lands back on main without a manual dispatch. The continuation form
29 self?.finish(domain: domain) 29 // also avoids sending a non-Sendable completion into loadItem's
30 } 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 let providers = (extensionContext?.inputItems ?? []) 38 let providers = (extensionContext?.inputItems ?? [])
36 .compactMap { $0 as? NSExtensionItem } 39 .compactMap { $0 as? NSExtensionItem }
37 .flatMap { $0.attachments ?? [] } 40 .flatMap { $0.attachments ?? [] }
@@ -39,14 +42,15 @@ final class ShareViewController: UIViewController {
39 guard let provider = providers.first(where: { 42 guard let provider = providers.first(where: {
40 $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) 43 $0.hasItemConformingToTypeIdentifier(UTType.url.identifier)
41 }) else { 44 }) else {
42 completion(nil) 45 return nil
43 return
44 } 46 }
45 47
46 provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in 48 return await withCheckedContinuation { continuation in
47 let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } 49 provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
48 let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines) 50 let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) }
49 completion(host?.isEmpty == false ? host : 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